mirror of
https://github.com/bitwarden/browser
synced 2025-12-10 21:33:27 +00:00
[PM-22090] Delete password on Windows desktop throws incorrect error (#15070)
* delete password on Windows desktop throws incorrect error * delete password on Windows desktop throws incorrect error * napi documentation improvements * napi documentation update * better logging verbosity * desktop native clippy errors * unit test coverage * napi TS documentation JS language friendly * fixing merge conflicts
This commit is contained in:
@@ -1,10 +1,12 @@
|
|||||||
|
use crate::password::PASSWORD_NOT_FOUND;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use security_framework::passwords::{
|
use security_framework::passwords::{
|
||||||
delete_generic_password, get_generic_password, set_generic_password,
|
delete_generic_password, get_generic_password, set_generic_password,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub async fn get_password(service: &str, account: &str) -> Result<String> {
|
pub async fn get_password(service: &str, account: &str) -> Result<String> {
|
||||||
let result = String::from_utf8(get_generic_password(service, account)?)?;
|
let password = get_generic_password(service, account).map_err(convert_error)?;
|
||||||
|
let result = String::from_utf8(password)?;
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14,7 +16,7 @@ pub async fn set_password(service: &str, account: &str, password: &str) -> Resul
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_password(service: &str, account: &str) -> Result<()> {
|
pub async fn delete_password(service: &str, account: &str) -> Result<()> {
|
||||||
delete_generic_password(service, account)?;
|
delete_generic_password(service, account).map_err(convert_error)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,6 +24,15 @@ pub async fn is_available() -> Result<bool> {
|
|||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn convert_error(e: security_framework::base::Error) -> anyhow::Error {
|
||||||
|
match e.code() {
|
||||||
|
security_framework_sys::base::errSecItemNotFound => {
|
||||||
|
anyhow::anyhow!(PASSWORD_NOT_FOUND)
|
||||||
|
}
|
||||||
|
_ => anyhow::anyhow!(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -44,10 +55,7 @@ mod tests {
|
|||||||
// Ensure password is deleted
|
// Ensure password is deleted
|
||||||
match get_password("BitwardenTest", "BitwardenTest").await {
|
match get_password("BitwardenTest", "BitwardenTest").await {
|
||||||
Ok(_) => panic!("Got a result"),
|
Ok(_) => panic!("Got a result"),
|
||||||
Err(e) => assert_eq!(
|
Err(e) => assert_eq!(PASSWORD_NOT_FOUND, e.to_string()),
|
||||||
"The specified item could not be found in the keychain.",
|
|
||||||
e.to_string()
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,10 +63,7 @@ mod tests {
|
|||||||
async fn test_error_no_password() {
|
async fn test_error_no_password() {
|
||||||
match get_password("Unknown", "Unknown").await {
|
match get_password("Unknown", "Unknown").await {
|
||||||
Ok(_) => panic!("Got a result"),
|
Ok(_) => panic!("Got a result"),
|
||||||
Err(e) => assert_eq!(
|
Err(e) => assert_eq!(PASSWORD_NOT_FOUND, e.to_string()),
|
||||||
"The specified item could not be found in the keychain.",
|
|
||||||
e.to_string()
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
pub const PASSWORD_NOT_FOUND: &str = "Password not found.";
|
||||||
|
|
||||||
#[allow(clippy::module_inception)]
|
#[allow(clippy::module_inception)]
|
||||||
#[cfg_attr(target_os = "linux", path = "unix.rs")]
|
#[cfg_attr(target_os = "linux", path = "unix.rs")]
|
||||||
#[cfg_attr(target_os = "windows", path = "windows.rs")]
|
#[cfg_attr(target_os = "windows", path = "windows.rs")]
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::password::PASSWORD_NOT_FOUND;
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
use oo7::dbus::{self};
|
use oo7::dbus::{self};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -20,7 +21,7 @@ async fn get_password_new(service: &str, account: &str) -> Result<String> {
|
|||||||
let secret = res.secret().await?;
|
let secret = res.secret().await?;
|
||||||
Ok(String::from_utf8(secret.to_vec())?)
|
Ok(String::from_utf8(secret.to_vec())?)
|
||||||
}
|
}
|
||||||
None => Err(anyhow!("no result")),
|
None => Err(anyhow!(PASSWORD_NOT_FOUND)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +47,7 @@ async fn get_password_legacy(service: &str, account: &str) -> Result<String> {
|
|||||||
set_password(service, account, &secret_string).await?;
|
set_password(service, account, &secret_string).await?;
|
||||||
Ok(secret_string)
|
Ok(secret_string)
|
||||||
}
|
}
|
||||||
None => Err(anyhow!("no result")),
|
None => Err(anyhow!(PASSWORD_NOT_FOUND)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,7 +153,7 @@ mod tests {
|
|||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
panic!("Got a result")
|
panic!("Got a result")
|
||||||
}
|
}
|
||||||
Err(e) => assert_eq!("no result", e.to_string()),
|
Err(e) => assert_eq!(PASSWORD_NOT_FOUND, e.to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,7 +161,7 @@ mod tests {
|
|||||||
async fn test_error_no_password() {
|
async fn test_error_no_password() {
|
||||||
match get_password("Unknown", "Unknown").await {
|
match get_password("Unknown", "Unknown").await {
|
||||||
Ok(_) => panic!("Got a result"),
|
Ok(_) => panic!("Got a result"),
|
||||||
Err(e) => assert_eq!("no result", e.to_string()),
|
Err(e) => assert_eq!(PASSWORD_NOT_FOUND, e.to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::password::PASSWORD_NOT_FOUND;
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
use widestring::{U16CString, U16String};
|
use widestring::{U16CString, U16String};
|
||||||
use windows::{
|
use windows::{
|
||||||
@@ -79,7 +80,9 @@ pub async fn set_password(service: &str, account: &str, password: &str) -> Resul
|
|||||||
pub async fn delete_password(service: &str, account: &str) -> Result<()> {
|
pub async fn delete_password(service: &str, account: &str) -> Result<()> {
|
||||||
let target_name = U16CString::from_str(target_name(service, account))?;
|
let target_name = U16CString::from_str(target_name(service, account))?;
|
||||||
|
|
||||||
unsafe { CredDeleteW(PCWSTR(target_name.as_ptr()), CRED_TYPE_GENERIC, None)? };
|
let result = unsafe { CredDeleteW(PCWSTR(target_name.as_ptr()), CRED_TYPE_GENERIC, None) };
|
||||||
|
|
||||||
|
result.map_err(|e| anyhow!(convert_error(e)))?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -95,7 +98,7 @@ fn target_name(service: &str, account: &str) -> String {
|
|||||||
// Convert the internal WIN32 errors to descriptive messages
|
// Convert the internal WIN32 errors to descriptive messages
|
||||||
fn convert_error(e: windows::core::Error) -> String {
|
fn convert_error(e: windows::core::Error) -> String {
|
||||||
if e == ERROR_NOT_FOUND.into() {
|
if e == ERROR_NOT_FOUND.into() {
|
||||||
return "Password not found.".to_string();
|
return PASSWORD_NOT_FOUND.to_string();
|
||||||
}
|
}
|
||||||
e.to_string()
|
e.to_string()
|
||||||
}
|
}
|
||||||
@@ -122,7 +125,7 @@ mod tests {
|
|||||||
// Ensure password is deleted
|
// Ensure password is deleted
|
||||||
match get_password("BitwardenTest", "BitwardenTest").await {
|
match get_password("BitwardenTest", "BitwardenTest").await {
|
||||||
Ok(_) => panic!("Got a result"),
|
Ok(_) => panic!("Got a result"),
|
||||||
Err(e) => assert_eq!("Password not found.", e.to_string()),
|
Err(e) => assert_eq!(PASSWORD_NOT_FOUND, e.to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,7 +133,7 @@ mod tests {
|
|||||||
async fn test_error_no_password() {
|
async fn test_error_no_password() {
|
||||||
match get_password("BitwardenTest", "BitwardenTest").await {
|
match get_password("BitwardenTest", "BitwardenTest").await {
|
||||||
Ok(_) => panic!("Got a result"),
|
Ok(_) => panic!("Got a result"),
|
||||||
Err(e) => assert_eq!("Password not found.", e.to_string()),
|
Err(e) => assert_eq!(PASSWORD_NOT_FOUND, e.to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
17
apps/desktop/desktop_native/napi/index.d.ts
vendored
17
apps/desktop/desktop_native/napi/index.d.ts
vendored
@@ -4,18 +4,31 @@
|
|||||||
/* auto-generated by NAPI-RS */
|
/* auto-generated by NAPI-RS */
|
||||||
|
|
||||||
export declare namespace passwords {
|
export declare namespace passwords {
|
||||||
/** Fetch the stored password from the keychain. */
|
/** The error message returned when a password is not found during retrieval or deletion. */
|
||||||
|
export const PASSWORD_NOT_FOUND: string
|
||||||
|
/**
|
||||||
|
* Fetch the stored password from the keychain.
|
||||||
|
* Throws {@link Error} with message {@link PASSWORD_NOT_FOUND} if the password does not exist.
|
||||||
|
*/
|
||||||
export function getPassword(service: string, account: string): Promise<string>
|
export function getPassword(service: string, account: string): Promise<string>
|
||||||
/** Save the password to the keychain. Adds an entry if none exists otherwise updates the existing entry. */
|
/** Save the password to the keychain. Adds an entry if none exists otherwise updates the existing entry. */
|
||||||
export function setPassword(service: string, account: string, password: string): Promise<void>
|
export function setPassword(service: string, account: string, password: string): Promise<void>
|
||||||
/** Delete the stored password from the keychain. */
|
/**
|
||||||
|
* Delete the stored password from the keychain.
|
||||||
|
* Throws {@link Error} with message {@link PASSWORD_NOT_FOUND} if the password does not exist.
|
||||||
|
*/
|
||||||
export function deletePassword(service: string, account: string): Promise<void>
|
export function deletePassword(service: string, account: string): Promise<void>
|
||||||
|
/** Checks if the os secure storage is available */
|
||||||
export function isAvailable(): Promise<boolean>
|
export function isAvailable(): Promise<boolean>
|
||||||
}
|
}
|
||||||
export declare namespace biometrics {
|
export declare namespace biometrics {
|
||||||
export function prompt(hwnd: Buffer, message: string): Promise<boolean>
|
export function prompt(hwnd: Buffer, message: string): Promise<boolean>
|
||||||
export function available(): Promise<boolean>
|
export function available(): Promise<boolean>
|
||||||
export function setBiometricSecret(service: string, account: string, secret: string, keyMaterial: KeyMaterial | undefined | null, ivB64: string): Promise<string>
|
export function setBiometricSecret(service: string, account: string, secret: string, keyMaterial: KeyMaterial | undefined | null, ivB64: string): Promise<string>
|
||||||
|
/**
|
||||||
|
* Retrieves the biometric secret for the given service and account.
|
||||||
|
* Throws Error with message [`passwords::PASSWORD_NOT_FOUND`] if the secret does not exist.
|
||||||
|
*/
|
||||||
export function getBiometricSecret(service: string, account: string, keyMaterial?: KeyMaterial | undefined | null): Promise<string>
|
export function getBiometricSecret(service: string, account: string, keyMaterial?: KeyMaterial | undefined | null): Promise<string>
|
||||||
/**
|
/**
|
||||||
* Derives key material from biometric data. Returns a string encoded with a
|
* Derives key material from biometric data. Returns a string encoded with a
|
||||||
|
|||||||
@@ -6,7 +6,12 @@ mod registry;
|
|||||||
|
|
||||||
#[napi]
|
#[napi]
|
||||||
pub mod passwords {
|
pub mod passwords {
|
||||||
|
/// The error message returned when a password is not found during retrieval or deletion.
|
||||||
|
#[napi]
|
||||||
|
pub const PASSWORD_NOT_FOUND: &str = desktop_core::password::PASSWORD_NOT_FOUND;
|
||||||
|
|
||||||
/// Fetch the stored password from the keychain.
|
/// Fetch the stored password from the keychain.
|
||||||
|
/// Throws {@link Error} with message {@link PASSWORD_NOT_FOUND} if the password does not exist.
|
||||||
#[napi]
|
#[napi]
|
||||||
pub async fn get_password(service: String, account: String) -> napi::Result<String> {
|
pub async fn get_password(service: String, account: String) -> napi::Result<String> {
|
||||||
desktop_core::password::get_password(&service, &account)
|
desktop_core::password::get_password(&service, &account)
|
||||||
@@ -27,6 +32,7 @@ pub mod passwords {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Delete the stored password from the keychain.
|
/// Delete the stored password from the keychain.
|
||||||
|
/// Throws {@link Error} with message {@link PASSWORD_NOT_FOUND} if the password does not exist.
|
||||||
#[napi]
|
#[napi]
|
||||||
pub async fn delete_password(service: String, account: String) -> napi::Result<()> {
|
pub async fn delete_password(service: String, account: String) -> napi::Result<()> {
|
||||||
desktop_core::password::delete_password(&service, &account)
|
desktop_core::password::delete_password(&service, &account)
|
||||||
@@ -34,7 +40,7 @@ pub mod passwords {
|
|||||||
.map_err(|e| napi::Error::from_reason(e.to_string()))
|
.map_err(|e| napi::Error::from_reason(e.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Checks if the os secure storage is available
|
/// Checks if the os secure storage is available
|
||||||
#[napi]
|
#[napi]
|
||||||
pub async fn is_available() -> napi::Result<bool> {
|
pub async fn is_available() -> napi::Result<bool> {
|
||||||
desktop_core::password::is_available()
|
desktop_core::password::is_available()
|
||||||
@@ -84,6 +90,8 @@ pub mod biometrics {
|
|||||||
.map_err(|e| napi::Error::from_reason(e.to_string()))
|
.map_err(|e| napi::Error::from_reason(e.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Retrieves the biometric secret for the given service and account.
|
||||||
|
/// Throws Error with message [`passwords::PASSWORD_NOT_FOUND`] if the secret does not exist.
|
||||||
#[napi]
|
#[napi]
|
||||||
pub async fn get_biometric_secret(
|
pub async fn get_biometric_secret(
|
||||||
service: String,
|
service: String,
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export class MainBiometricsIPCListener {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.logService.info(e);
|
this.logService.error("[Main Biometrics IPC Listener] %s failed", message.action, e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export class MainBiometricsService extends DesktopBiometricsService {
|
|||||||
} else if (platform === "darwin") {
|
} else if (platform === "darwin") {
|
||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
const OsBiometricsServiceMac = require("./os-biometrics-mac.service").default;
|
const OsBiometricsServiceMac = require("./os-biometrics-mac.service").default;
|
||||||
this.osBiometricsService = new OsBiometricsServiceMac(this.i18nService);
|
this.osBiometricsService = new OsBiometricsServiceMac(this.i18nService, this.logService);
|
||||||
} else if (platform === "linux") {
|
} else if (platform === "linux") {
|
||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
const OsBiometricsServiceLinux = require("./os-biometrics-linux.service").default;
|
const OsBiometricsServiceLinux = require("./os-biometrics-linux.service").default;
|
||||||
@@ -48,6 +48,7 @@ export class MainBiometricsService extends DesktopBiometricsService {
|
|||||||
this.biometricStateService,
|
this.biometricStateService,
|
||||||
this.encryptService,
|
this.encryptService,
|
||||||
this.cryptoFunctionService,
|
this.cryptoFunctionService,
|
||||||
|
this.logService,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
throw new Error("Unsupported platform");
|
throw new Error("Unsupported platform");
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { mock } from "jest-mock-extended";
|
||||||
|
|
||||||
|
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
|
||||||
|
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||||
|
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||||
|
import { UserId } from "@bitwarden/common/types/guid";
|
||||||
|
import { passwords } from "@bitwarden/desktop-napi";
|
||||||
|
import { BiometricStateService } from "@bitwarden/key-management";
|
||||||
|
|
||||||
|
import OsBiometricsServiceLinux from "./os-biometrics-linux.service";
|
||||||
|
|
||||||
|
jest.mock("@bitwarden/desktop-napi", () => ({
|
||||||
|
biometrics: {
|
||||||
|
setBiometricSecret: jest.fn(),
|
||||||
|
getBiometricSecret: jest.fn(),
|
||||||
|
deleteBiometricSecret: jest.fn(),
|
||||||
|
prompt: jest.fn(),
|
||||||
|
available: jest.fn(),
|
||||||
|
deriveKeyMaterial: jest.fn(),
|
||||||
|
},
|
||||||
|
passwords: {
|
||||||
|
deletePassword: jest.fn(),
|
||||||
|
getPassword: jest.fn(),
|
||||||
|
isAvailable: jest.fn(),
|
||||||
|
PASSWORD_NOT_FOUND: "Password not found",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("OsBiometricsServiceLinux", () => {
|
||||||
|
let service: OsBiometricsServiceLinux;
|
||||||
|
let logService: LogService;
|
||||||
|
|
||||||
|
const mockUserId = "test-user-id" as UserId;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const biometricStateService = mock<BiometricStateService>();
|
||||||
|
const encryptService = mock<EncryptService>();
|
||||||
|
const cryptoFunctionService = mock<CryptoFunctionService>();
|
||||||
|
logService = mock<LogService>();
|
||||||
|
service = new OsBiometricsServiceLinux(
|
||||||
|
biometricStateService,
|
||||||
|
encryptService,
|
||||||
|
cryptoFunctionService,
|
||||||
|
logService,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deleteBiometricKey", () => {
|
||||||
|
const serviceName = "Bitwarden_biometric";
|
||||||
|
const keyName = "test-user-id_user_biometric";
|
||||||
|
|
||||||
|
it("should delete biometric key successfully", async () => {
|
||||||
|
await service.deleteBiometricKey(mockUserId);
|
||||||
|
|
||||||
|
expect(passwords.deletePassword).toHaveBeenCalledWith(serviceName, keyName);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not throw error if key not found", async () => {
|
||||||
|
passwords.deletePassword = jest
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValueOnce(new Error(passwords.PASSWORD_NOT_FOUND));
|
||||||
|
|
||||||
|
await service.deleteBiometricKey(mockUserId);
|
||||||
|
|
||||||
|
expect(passwords.deletePassword).toHaveBeenCalledWith(serviceName, keyName);
|
||||||
|
expect(logService.debug).toHaveBeenCalledWith(
|
||||||
|
"[OsBiometricService] Biometric key %s not found for service %s.",
|
||||||
|
keyName,
|
||||||
|
serviceName,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw error for unexpected errors", async () => {
|
||||||
|
const error = new Error("Unexpected error");
|
||||||
|
passwords.deletePassword = jest.fn().mockRejectedValueOnce(error);
|
||||||
|
|
||||||
|
await expect(service.deleteBiometricKey(mockUserId)).rejects.toThrow(error);
|
||||||
|
|
||||||
|
expect(passwords.deletePassword).toHaveBeenCalledWith(serviceName, keyName);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import { spawn } from "child_process";
|
|||||||
|
|
||||||
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
|
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
|
||||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||||
|
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||||
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||||
@@ -42,6 +43,7 @@ export default class OsBiometricsServiceLinux implements OsBiometricService {
|
|||||||
private biometricStateService: BiometricStateService,
|
private biometricStateService: BiometricStateService,
|
||||||
private encryptService: EncryptService,
|
private encryptService: EncryptService,
|
||||||
private cryptoFunctionService: CryptoFunctionService,
|
private cryptoFunctionService: CryptoFunctionService,
|
||||||
|
private logService: LogService,
|
||||||
) {}
|
) {}
|
||||||
private _iv: string | null = null;
|
private _iv: string | null = null;
|
||||||
// Use getKeyMaterial helper instead of direct access
|
// Use getKeyMaterial helper instead of direct access
|
||||||
@@ -62,7 +64,19 @@ export default class OsBiometricsServiceLinux implements OsBiometricService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
async deleteBiometricKey(userId: UserId): Promise<void> {
|
async deleteBiometricKey(userId: UserId): Promise<void> {
|
||||||
await passwords.deletePassword(SERVICE, getLookupKeyForUser(userId));
|
try {
|
||||||
|
await passwords.deletePassword(SERVICE, getLookupKeyForUser(userId));
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error && e.message === passwords.PASSWORD_NOT_FOUND) {
|
||||||
|
this.logService.debug(
|
||||||
|
"[OsBiometricService] Biometric key %s not found for service %s.",
|
||||||
|
getLookupKeyForUser(userId),
|
||||||
|
SERVICE,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getBiometricKey(userId: UserId): Promise<SymmetricCryptoKey | null> {
|
async getBiometricKey(userId: UserId): Promise<SymmetricCryptoKey | null> {
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { mock } from "jest-mock-extended";
|
||||||
|
|
||||||
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
|
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||||
|
import { UserId } from "@bitwarden/common/types/guid";
|
||||||
|
import { passwords } from "@bitwarden/desktop-napi";
|
||||||
|
|
||||||
|
import OsBiometricsServiceMac from "./os-biometrics-mac.service";
|
||||||
|
|
||||||
|
jest.mock("@bitwarden/desktop-napi", () => ({
|
||||||
|
biometrics: {
|
||||||
|
setBiometricSecret: jest.fn(),
|
||||||
|
getBiometricSecret: jest.fn(),
|
||||||
|
deleteBiometricSecret: jest.fn(),
|
||||||
|
prompt: jest.fn(),
|
||||||
|
available: jest.fn(),
|
||||||
|
deriveKeyMaterial: jest.fn(),
|
||||||
|
},
|
||||||
|
passwords: {
|
||||||
|
deletePassword: jest.fn(),
|
||||||
|
getPassword: jest.fn(),
|
||||||
|
isAvailable: jest.fn(),
|
||||||
|
PASSWORD_NOT_FOUND: "Password not found",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("OsBiometricsServiceMac", () => {
|
||||||
|
let service: OsBiometricsServiceMac;
|
||||||
|
let i18nService: I18nService;
|
||||||
|
let logService: LogService;
|
||||||
|
|
||||||
|
const mockUserId = "test-user-id" as UserId;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
i18nService = mock<I18nService>();
|
||||||
|
logService = mock<LogService>();
|
||||||
|
service = new OsBiometricsServiceMac(i18nService, logService);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deleteBiometricKey", () => {
|
||||||
|
const serviceName = "Bitwarden_biometric";
|
||||||
|
const keyName = "test-user-id_user_biometric";
|
||||||
|
|
||||||
|
it("should delete biometric key successfully", async () => {
|
||||||
|
await service.deleteBiometricKey(mockUserId);
|
||||||
|
|
||||||
|
expect(passwords.deletePassword).toHaveBeenCalledWith(serviceName, keyName);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not throw error if key not found", async () => {
|
||||||
|
passwords.deletePassword = jest
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValueOnce(new Error(passwords.PASSWORD_NOT_FOUND));
|
||||||
|
|
||||||
|
await service.deleteBiometricKey(mockUserId);
|
||||||
|
|
||||||
|
expect(passwords.deletePassword).toHaveBeenCalledWith(serviceName, keyName);
|
||||||
|
expect(logService.debug).toHaveBeenCalledWith(
|
||||||
|
"[OsBiometricService] Biometric key %s not found for service %s.",
|
||||||
|
keyName,
|
||||||
|
serviceName,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw error for unexpected errors", async () => {
|
||||||
|
const error = new Error("Unexpected error");
|
||||||
|
passwords.deletePassword = jest.fn().mockRejectedValueOnce(error);
|
||||||
|
|
||||||
|
await expect(service.deleteBiometricKey(mockUserId)).rejects.toThrow(error);
|
||||||
|
|
||||||
|
expect(passwords.deletePassword).toHaveBeenCalledWith(serviceName, keyName);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { systemPreferences } from "electron";
|
import { systemPreferences } from "electron";
|
||||||
|
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
|
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||||
import { UserId } from "@bitwarden/common/types/guid";
|
import { UserId } from "@bitwarden/common/types/guid";
|
||||||
import { passwords } from "@bitwarden/desktop-napi";
|
import { passwords } from "@bitwarden/desktop-napi";
|
||||||
@@ -14,7 +15,10 @@ function getLookupKeyForUser(userId: UserId): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default class OsBiometricsServiceMac implements OsBiometricService {
|
export default class OsBiometricsServiceMac implements OsBiometricService {
|
||||||
constructor(private i18nservice: I18nService) {}
|
constructor(
|
||||||
|
private i18nservice: I18nService,
|
||||||
|
private logService: LogService,
|
||||||
|
) {}
|
||||||
|
|
||||||
async supportsBiometrics(): Promise<boolean> {
|
async supportsBiometrics(): Promise<boolean> {
|
||||||
return systemPreferences.canPromptTouchID();
|
return systemPreferences.canPromptTouchID();
|
||||||
@@ -52,7 +56,19 @@ export default class OsBiometricsServiceMac implements OsBiometricService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async deleteBiometricKey(user: UserId): Promise<void> {
|
async deleteBiometricKey(user: UserId): Promise<void> {
|
||||||
return await passwords.deletePassword(SERVICE, getLookupKeyForUser(user));
|
try {
|
||||||
|
return await passwords.deletePassword(SERVICE, getLookupKeyForUser(user));
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error && e.message === passwords.PASSWORD_NOT_FOUND) {
|
||||||
|
this.logService.debug(
|
||||||
|
"[OsBiometricService] Biometric key %s not found for service %s.",
|
||||||
|
getLookupKeyForUser(user),
|
||||||
|
SERVICE,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async valueUpToDate(user: UserId, key: SymmetricCryptoKey): Promise<boolean> {
|
private async valueUpToDate(user: UserId, key: SymmetricCryptoKey): Promise<boolean> {
|
||||||
|
|||||||
@@ -6,8 +6,11 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
|
|||||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||||
import { UserId } from "@bitwarden/common/types/guid";
|
import { UserId } from "@bitwarden/common/types/guid";
|
||||||
|
import { passwords } from "@bitwarden/desktop-napi";
|
||||||
import { BiometricsStatus, BiometricStateService } from "@bitwarden/key-management";
|
import { BiometricsStatus, BiometricStateService } from "@bitwarden/key-management";
|
||||||
|
|
||||||
|
import { WindowMain } from "../../main/window.main";
|
||||||
|
|
||||||
import OsBiometricsServiceWindows from "./os-biometrics-windows.service";
|
import OsBiometricsServiceWindows from "./os-biometrics-windows.service";
|
||||||
|
|
||||||
jest.mock("@bitwarden/desktop-napi", () => ({
|
jest.mock("@bitwarden/desktop-napi", () => ({
|
||||||
@@ -15,28 +18,37 @@ jest.mock("@bitwarden/desktop-napi", () => ({
|
|||||||
available: jest.fn(),
|
available: jest.fn(),
|
||||||
setBiometricSecret: jest.fn(),
|
setBiometricSecret: jest.fn(),
|
||||||
getBiometricSecret: jest.fn(),
|
getBiometricSecret: jest.fn(),
|
||||||
deriveKeyMaterial: jest.fn(),
|
deleteBiometricSecret: jest.fn(),
|
||||||
prompt: jest.fn(),
|
prompt: jest.fn(),
|
||||||
|
deriveKeyMaterial: jest.fn(),
|
||||||
},
|
},
|
||||||
passwords: {
|
passwords: {
|
||||||
getPassword: jest.fn(),
|
getPassword: jest.fn(),
|
||||||
deletePassword: jest.fn(),
|
deletePassword: jest.fn(),
|
||||||
|
isAvailable: jest.fn(),
|
||||||
|
PASSWORD_NOT_FOUND: "Password not found",
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("OsBiometricsServiceWindows", () => {
|
describe("OsBiometricsServiceWindows", () => {
|
||||||
let service: OsBiometricsServiceWindows;
|
let service: OsBiometricsServiceWindows;
|
||||||
|
let i18nService: I18nService;
|
||||||
|
let windowMain: WindowMain;
|
||||||
|
let logService: LogService;
|
||||||
let biometricStateService: BiometricStateService;
|
let biometricStateService: BiometricStateService;
|
||||||
|
|
||||||
|
const mockUserId = "test-user-id" as UserId;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
const i18nService = mock<I18nService>();
|
i18nService = mock<I18nService>();
|
||||||
const logService = mock<LogService>();
|
windowMain = mock<WindowMain>();
|
||||||
|
logService = mock<LogService>();
|
||||||
biometricStateService = mock<BiometricStateService>();
|
biometricStateService = mock<BiometricStateService>();
|
||||||
const encryptionService = mock<EncryptService>();
|
const encryptionService = mock<EncryptService>();
|
||||||
const cryptoFunctionService = mock<CryptoFunctionService>();
|
const cryptoFunctionService = mock<CryptoFunctionService>();
|
||||||
service = new OsBiometricsServiceWindows(
|
service = new OsBiometricsServiceWindows(
|
||||||
i18nService,
|
i18nService,
|
||||||
null,
|
windowMain,
|
||||||
logService,
|
logService,
|
||||||
biometricStateService,
|
biometricStateService,
|
||||||
encryptionService,
|
encryptionService,
|
||||||
@@ -81,7 +93,7 @@ describe("OsBiometricsServiceWindows", () => {
|
|||||||
cryptoFunctionService = mock<CryptoFunctionService>();
|
cryptoFunctionService = mock<CryptoFunctionService>();
|
||||||
service = new OsBiometricsServiceWindows(
|
service = new OsBiometricsServiceWindows(
|
||||||
mock<I18nService>(),
|
mock<I18nService>(),
|
||||||
null,
|
windowMain,
|
||||||
mock<LogService>(),
|
mock<LogService>(),
|
||||||
biometricStateService,
|
biometricStateService,
|
||||||
encryptionService,
|
encryptionService,
|
||||||
@@ -140,4 +152,97 @@ describe("OsBiometricsServiceWindows", () => {
|
|||||||
expect((service as any).clientKeyHalves.get(userId.toString())).toBeNull();
|
expect((service as any).clientKeyHalves.get(userId.toString())).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("deleteBiometricKey", () => {
|
||||||
|
const serviceName = "Bitwarden_biometric";
|
||||||
|
const keyName = "test-user-id_user_biometric";
|
||||||
|
const witnessKeyName = "test-user-id_user_biometric_witness";
|
||||||
|
|
||||||
|
it("should delete biometric key successfully", async () => {
|
||||||
|
await service.deleteBiometricKey(mockUserId);
|
||||||
|
|
||||||
|
expect(passwords.deletePassword).toHaveBeenCalledWith(serviceName, keyName);
|
||||||
|
expect(passwords.deletePassword).toHaveBeenCalledWith(serviceName, witnessKeyName);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[false, false],
|
||||||
|
[false, true],
|
||||||
|
[true, false],
|
||||||
|
])(
|
||||||
|
"should not throw error if key found: %s and witness key found: %s",
|
||||||
|
async (keyFound, witnessKeyFound) => {
|
||||||
|
passwords.deletePassword = jest.fn().mockImplementation((_, account) => {
|
||||||
|
if (account === keyName) {
|
||||||
|
if (!keyFound) {
|
||||||
|
throw new Error(passwords.PASSWORD_NOT_FOUND);
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
if (account === witnessKeyName) {
|
||||||
|
if (!witnessKeyFound) {
|
||||||
|
throw new Error(passwords.PASSWORD_NOT_FOUND);
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
throw new Error("Unexpected key");
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.deleteBiometricKey(mockUserId);
|
||||||
|
|
||||||
|
expect(passwords.deletePassword).toHaveBeenCalledWith(serviceName, keyName);
|
||||||
|
expect(passwords.deletePassword).toHaveBeenCalledWith(serviceName, witnessKeyName);
|
||||||
|
if (!keyFound) {
|
||||||
|
expect(logService.debug).toHaveBeenCalledWith(
|
||||||
|
"[OsBiometricService] Biometric key %s not found for service %s.",
|
||||||
|
keyName,
|
||||||
|
serviceName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!witnessKeyFound) {
|
||||||
|
expect(logService.debug).toHaveBeenCalledWith(
|
||||||
|
"[OsBiometricService] Biometric witness key %s not found for service %s.",
|
||||||
|
witnessKeyName,
|
||||||
|
serviceName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("should throw error when deletePassword for key throws unexpected errors", async () => {
|
||||||
|
const error = new Error("Unexpected error");
|
||||||
|
passwords.deletePassword = jest.fn().mockImplementation((_, account) => {
|
||||||
|
if (account === keyName) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (account === witnessKeyName) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
throw new Error("Unexpected key");
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.deleteBiometricKey(mockUserId)).rejects.toThrow(error);
|
||||||
|
|
||||||
|
expect(passwords.deletePassword).toHaveBeenCalledWith(serviceName, keyName);
|
||||||
|
expect(passwords.deletePassword).not.toHaveBeenCalledWith(serviceName, witnessKeyName);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw error when deletePassword for witness key throws unexpected errors", async () => {
|
||||||
|
const error = new Error("Unexpected error");
|
||||||
|
passwords.deletePassword = jest.fn().mockImplementation((_, account) => {
|
||||||
|
if (account === keyName) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
if (account === witnessKeyName) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new Error("Unexpected key");
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.deleteBiometricKey(mockUserId)).rejects.toThrow(error);
|
||||||
|
|
||||||
|
expect(passwords.deletePassword).toHaveBeenCalledWith(serviceName, keyName);
|
||||||
|
expect(passwords.deletePassword).toHaveBeenCalledWith(serviceName, witnessKeyName);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -116,8 +116,32 @@ export default class OsBiometricsServiceWindows implements OsBiometricService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async deleteBiometricKey(userId: UserId): Promise<void> {
|
async deleteBiometricKey(userId: UserId): Promise<void> {
|
||||||
await passwords.deletePassword(SERVICE, getLookupKeyForUser(userId));
|
try {
|
||||||
await passwords.deletePassword(SERVICE, getLookupKeyForUser(userId) + KEY_WITNESS_SUFFIX);
|
await passwords.deletePassword(SERVICE, getLookupKeyForUser(userId));
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error && e.message === passwords.PASSWORD_NOT_FOUND) {
|
||||||
|
this.logService.debug(
|
||||||
|
"[OsBiometricService] Biometric key %s not found for service %s.",
|
||||||
|
getLookupKeyForUser(userId),
|
||||||
|
SERVICE,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await passwords.deletePassword(SERVICE, getLookupKeyForUser(userId) + KEY_WITNESS_SUFFIX);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error && e.message === passwords.PASSWORD_NOT_FOUND) {
|
||||||
|
this.logService.debug(
|
||||||
|
"[OsBiometricService] Biometric witness key %s not found for service %s.",
|
||||||
|
getLookupKeyForUser(userId) + KEY_WITNESS_SUFFIX,
|
||||||
|
SERVICE,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async authenticateBiometric(): Promise<boolean> {
|
async authenticateBiometric(): Promise<boolean> {
|
||||||
@@ -227,8 +251,19 @@ export default class OsBiometricsServiceWindows implements OsBiometricService {
|
|||||||
storageKey + KEY_WITNESS_SUFFIX,
|
storageKey + KEY_WITNESS_SUFFIX,
|
||||||
witnessKeyMaterial,
|
witnessKeyMaterial,
|
||||||
);
|
);
|
||||||
} catch {
|
} catch (e) {
|
||||||
this.logService.debug("Error retrieving witness key, assuming value is not up to date.");
|
if (e instanceof Error && e.message === passwords.PASSWORD_NOT_FOUND) {
|
||||||
|
this.logService.debug(
|
||||||
|
"[OsBiometricService] Biometric witness key %s not found for service %s, value is not up to date.",
|
||||||
|
storageKey + KEY_WITNESS_SUFFIX,
|
||||||
|
service,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.logService.error(
|
||||||
|
"[OsBiometricService] Error retrieving witness key, assuming value is not up to date.",
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,13 +35,13 @@ export class DesktopCredentialStorageListener {
|
|||||||
}
|
}
|
||||||
return val;
|
return val;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (
|
if (e instanceof Error && e.message === passwords.PASSWORD_NOT_FOUND) {
|
||||||
e.message === "Password not found." ||
|
if (message.action === "hasPassword") {
|
||||||
e.message === "The specified item could not be found in the keychain."
|
return false;
|
||||||
) {
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
this.logService.info(e);
|
this.logService.error("[Credential Storage Listener] %s failed", message.action, e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user