1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-22 20:34:04 +00:00

Merge branch 'main' into km/auto-kdf

This commit is contained in:
Bernd Schoolmann
2025-11-03 12:49:56 +01:00
committed by GitHub
316 changed files with 16152 additions and 3166 deletions

View File

@@ -91,7 +91,7 @@ import { CipherShareRequest } from "../vault/models/request/cipher-share.request
import { CipherRequest } from "../vault/models/request/cipher.request";
import { AttachmentUploadDataResponse } from "../vault/models/response/attachment-upload-data.response";
import { AttachmentResponse } from "../vault/models/response/attachment.response";
import { CipherResponse } from "../vault/models/response/cipher.response";
import { CipherMiniResponse, CipherResponse } from "../vault/models/response/cipher.response";
import { OptionalCipherResponse } from "../vault/models/response/optional-cipher.response";
/**
@@ -215,7 +215,10 @@ export abstract class ApiService {
id: string,
request: CipherCollectionsRequest,
): Promise<OptionalCipherResponse>;
abstract putCipherCollectionsAdmin(id: string, request: CipherCollectionsRequest): Promise<any>;
abstract putCipherCollectionsAdmin(
id: string,
request: CipherCollectionsRequest,
): Promise<CipherMiniResponse>;
abstract postPurgeCiphers(
request: SecretVerificationRequest,
organizationId?: string,

View File

@@ -18,6 +18,8 @@ import {
NotificationResponse,
} from "../../models/response/notification.response";
import { EnvironmentService } from "../../platform/abstractions/environment.service";
import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service";
import { InsecureUrlNotAllowedError } from "../../services/api-errors";
import { AnonymousHubService as AnonymousHubServiceAbstraction } from "../abstractions/anonymous-hub.service";
export class AnonymousHubService implements AnonymousHubServiceAbstraction {
@@ -27,10 +29,14 @@ export class AnonymousHubService implements AnonymousHubServiceAbstraction {
constructor(
private environmentService: EnvironmentService,
private authRequestService: AuthRequestServiceAbstraction,
private platformUtilsService: PlatformUtilsService,
) {}
async createHubConnection(token: string) {
this.url = (await firstValueFrom(this.environmentService.environment$)).getNotificationsUrl();
if (!this.url.startsWith("https://") && !this.platformUtilsService.isDev()) {
throw new InsecureUrlNotAllowedError();
}
this.anonHubConnection = new HubConnectionBuilder()
.withUrl(this.url + "/anonymous-hub?Token=" + token, {

View File

@@ -166,7 +166,7 @@ export class DefaultDomainSettingsService implements DomainSettingsService {
if (!policy?.enabled || policy?.data == null) {
return null;
}
const data = policy.data?.defaultUriMatchStrategy;
const data = policy.data?.uriMatchDetection;
// Validate that data is a valid UriMatchStrategy value
return Object.values(UriMatchStrategy).includes(data) ? data : null;
}),

View File

@@ -37,6 +37,7 @@ export enum FeatureFlag {
ForceUpdateKDFSettings = "pm-18021-force-update-kdf-settings",
PM25174_DisableType0Decryption = "pm-25174-disable-type-0-decryption",
WindowsBiometricsV2 = "pm-25373-windows-biometrics-v2",
LinuxBiometricsV2 = "pm-26340-linux-biometrics-v2",
UnlockWithMasterPasswordUnlockData = "pm-23246-unlock-with-master-password-unlock-data",
NoLogoutOnKdfChange = "pm-23995-no-logout-on-kdf-change",
@@ -124,6 +125,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.ForceUpdateKDFSettings]: FALSE,
[FeatureFlag.PM25174_DisableType0Decryption]: FALSE,
[FeatureFlag.WindowsBiometricsV2]: FALSE,
[FeatureFlag.LinuxBiometricsV2]: FALSE,
[FeatureFlag.UnlockWithMasterPasswordUnlockData]: FALSE,
[FeatureFlag.NoLogoutOnKdfChange]: FALSE,

View File

@@ -10,8 +10,10 @@ import { Observable, Subscription } from "rxjs";
import { ApiService } from "../../../abstractions/api.service";
import { NotificationResponse } from "../../../models/response/notification.response";
import { InsecureUrlNotAllowedError } from "../../../services/api-errors";
import { UserId } from "../../../types/guid";
import { LogService } from "../../abstractions/log.service";
import { PlatformUtilsService } from "../../abstractions/platform-utils.service";
// 2 Minutes
const MIN_RECONNECT_TIME = 2 * 60 * 1000;
@@ -69,12 +71,17 @@ export class SignalRConnectionService {
constructor(
private readonly apiService: ApiService,
private readonly logService: LogService,
private readonly platformUtilsService: PlatformUtilsService,
private readonly hubConnectionBuilderFactory: () => HubConnectionBuilder = () =>
new HubConnectionBuilder(),
private readonly timeoutManager: TimeoutManager = globalThis,
) {}
connect$(userId: UserId, notificationsUrl: string) {
if (!notificationsUrl.startsWith("https://") && !this.platformUtilsService.isDev()) {
throw new InsecureUrlNotAllowedError();
}
return new Observable<SignalRNotification>((subsciber) => {
const connection = this.hubConnectionBuilderFactory()
.withUrl(notificationsUrl + "/hub", {

View File

@@ -0,0 +1,9 @@
export class InsecureUrlNotAllowedError extends Error {
constructor(url?: string) {
if (url === undefined) {
super("Insecure URL not allowed. All URLs must use HTTPS.");
} else {
super(`Insecure URL not allowed: ${url}. All URLs must use HTTPS.`);
}
}
}

View File

@@ -20,6 +20,7 @@ import { Environment, EnvironmentService } from "../platform/abstractions/enviro
import { LogService } from "../platform/abstractions/log.service";
import { PlatformUtilsService } from "../platform/abstractions/platform-utils.service";
import { InsecureUrlNotAllowedError } from "./api-errors";
import { ApiService, HttpOperations } from "./api.service";
describe("ApiService", () => {
@@ -411,4 +412,39 @@ describe("ApiService", () => {
).rejects.toMatchObject(error);
},
);
it("throws error when trying to fetch an insecure URL", async () => {
environmentService.getEnvironment$.calledWith(testActiveUser).mockReturnValue(
of({
getApiUrl: () => "http://example.com",
} satisfies Partial<Environment> as Environment),
);
httpOperations.createRequest.mockImplementation((url, request) => {
return {
url: url,
cache: request.cache,
credentials: request.credentials,
method: request.method,
mode: request.mode,
signal: request.signal ?? undefined,
headers: new Headers(request.headers),
} satisfies Partial<Request> as unknown as Request;
});
const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();
nativeFetch.mockImplementation((request) => {
return Promise.resolve({
ok: true,
status: 204,
headers: new Headers(),
} satisfies Partial<Response> as Response);
});
sut.nativeFetch = nativeFetch;
await expect(
async () => await sut.send("GET", "/something", null, true, true, null),
).rejects.toThrow(InsecureUrlNotAllowedError);
expect(nativeFetch).not.toHaveBeenCalled();
});
});

View File

@@ -117,6 +117,8 @@ import { AttachmentResponse } from "../vault/models/response/attachment.response
import { CipherResponse } from "../vault/models/response/cipher.response";
import { OptionalCipherResponse } from "../vault/models/response/optional-cipher.response";
import { InsecureUrlNotAllowedError } from "./api-errors";
export type HttpOperations = {
createRequest: (url: string, request: RequestInit) => Request;
};
@@ -1310,6 +1312,10 @@ export class ApiService implements ApiServiceAbstraction {
}
async fetch(request: Request): Promise<Response> {
if (!request.url.startsWith("https://") && !this.platformUtilsService.isDev()) {
throw new InsecureUrlNotAllowedError();
}
if (request.method === "GET") {
request.headers.set("Cache-Control", "no-store");
request.headers.set("Pragma", "no-cache");

View File

@@ -0,0 +1,55 @@
import type {
CipherRiskResult,
CipherRiskOptions,
ExposedPasswordResult,
PasswordReuseMap,
CipherId,
} from "@bitwarden/sdk-internal";
import { UserId } from "../../types/guid";
import { CipherView } from "../models/view/cipher.view";
export abstract class CipherRiskService {
/**
* Compute password risks for multiple ciphers.
* Only processes Login ciphers with passwords.
*
* @param ciphers - The ciphers to evaluate for password risks
* @param userId - The user ID for SDK client context
* @param options - Optional configuration for risk computation (passwordMap, checkExposed)
* @returns Array of CipherRisk results from SDK containing password_strength, exposed_result, and reuse_count
*/
abstract computeRiskForCiphers(
ciphers: CipherView[],
userId: UserId,
options?: CipherRiskOptions,
): Promise<CipherRiskResult[]>;
/**
* Compute password risk for a single cipher by its ID. Will automatically build a password reuse map
* from all the user's ciphers via the CipherService.
* @param cipherId
* @param userId
* @param checkExposed - Whether to check if the password has been exposed in data breaches via HIBP
* @returns CipherRisk result from SDK containing password_strength, exposed_result, and reuse_count
*/
abstract computeCipherRiskForUser(
cipherId: CipherId,
userId: UserId,
checkExposed?: boolean,
): Promise<CipherRiskResult>;
/**
* Build a password reuse map for the given ciphers.
* Maps each password to the number of times it appears across ciphers.
* Only processes Login ciphers with passwords.
*
* @param ciphers - The ciphers to analyze for password reuse
* @param userId - The user ID for SDK client context
* @returns A map of password to count of occurrences
*/
abstract buildPasswordReuseMap(ciphers: CipherView[], userId: UserId): Promise<PasswordReuseMap>;
}
// Re-export SDK types for convenience
export type { CipherRiskResult, CipherRiskOptions, ExposedPasswordResult, PasswordReuseMap };

View File

@@ -14,6 +14,11 @@ import { SshKeyApi } from "../api/ssh-key.api";
import { AttachmentResponse } from "./attachment.response";
import { PasswordHistoryResponse } from "./password-history.response";
export type CipherMiniResponse = Omit<
CipherResponse,
"edit" | "viewPassword" | "folderId" | "favorite" | "permissions"
>;
export class CipherResponse extends BaseResponse {
id: string;
organizationId: string;

View File

@@ -1117,7 +1117,13 @@ export class CipherService implements CipherServiceAbstraction {
async saveCollectionsWithServerAdmin(cipher: Cipher): Promise<Cipher> {
const request = new CipherCollectionsRequest(cipher.collectionIds);
const response = await this.apiService.putCipherCollectionsAdmin(cipher.id, request);
const data = new CipherData(response);
// The response will be incomplete with several properties missing values
// We will assign those properties values so the SDK decryption can complete
const completedResponse = new CipherResponse(response);
completedResponse.edit = true;
completedResponse.viewPassword = true;
completedResponse.favorite = false;
const data = new CipherData(completedResponse);
return new Cipher(data);
}

View File

@@ -0,0 +1,538 @@
import { mock } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import type { CipherRiskOptions, CipherId, CipherRiskResult } from "@bitwarden/sdk-internal";
import { asUuid } from "../../platform/abstractions/sdk/sdk.service";
import { MockSdkService } from "../../platform/spec/mock-sdk.service";
import { UserId } from "../../types/guid";
import { CipherService } from "../abstractions/cipher.service";
import { CipherType } from "../enums/cipher-type";
import { CipherView } from "../models/view/cipher.view";
import { LoginView } from "../models/view/login.view";
import { DefaultCipherRiskService } from "./default-cipher-risk.service";
describe("DefaultCipherRiskService", () => {
let cipherRiskService: DefaultCipherRiskService;
let sdkService: MockSdkService;
let mockCipherService: jest.Mocked<CipherService>;
const mockUserId = "test-user-id" as UserId;
const mockCipherId1 = "cbea34a8-bde4-46ad-9d19-b05001228ab2";
const mockCipherId2 = "cbea34a8-bde4-46ad-9d19-b05001228ab3";
const mockCipherId3 = "cbea34a8-bde4-46ad-9d19-b05001228ab4";
beforeEach(() => {
sdkService = new MockSdkService();
mockCipherService = mock<CipherService>();
cipherRiskService = new DefaultCipherRiskService(sdkService, mockCipherService);
});
afterEach(() => {
jest.clearAllMocks();
});
describe("computeRiskForCiphers", () => {
it("should call SDK cipher_risk().compute_risk() with correct parameters", async () => {
const mockClient = sdkService.simulate.userLogin(mockUserId);
const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep();
const mockRiskResults: CipherRiskResult[] = [
{
id: mockCipherId1 as any,
password_strength: 3,
exposed_result: { type: "NotChecked" },
reuse_count: undefined,
},
];
mockCipherRiskClient.compute_risk.mockResolvedValue(mockRiskResults);
const cipher = new CipherView();
cipher.id = mockCipherId1;
cipher.type = CipherType.Login;
cipher.login = new LoginView();
cipher.login.password = "test-password";
cipher.login.username = "test@example.com";
const options: CipherRiskOptions = {
checkExposed: true,
passwordMap: undefined,
hibpBaseUrl: undefined,
};
const results = await cipherRiskService.computeRiskForCiphers([cipher], mockUserId, options);
expect(mockCipherRiskClient.compute_risk).toHaveBeenCalledWith(
[
{
id: expect.anything(),
password: "test-password",
username: "test@example.com",
},
],
options,
);
expect(results).toEqual(mockRiskResults);
});
it("should filter out non-Login ciphers", async () => {
const mockClient = sdkService.simulate.userLogin(mockUserId);
const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep();
mockCipherRiskClient.compute_risk.mockResolvedValue([]);
const loginCipher = new CipherView();
loginCipher.id = mockCipherId1;
loginCipher.type = CipherType.Login;
loginCipher.login = new LoginView();
loginCipher.login.password = "password1";
const cardCipher = new CipherView();
cardCipher.id = mockCipherId2;
cardCipher.type = CipherType.Card;
const identityCipher = new CipherView();
identityCipher.id = mockCipherId3;
identityCipher.type = CipherType.Identity;
await cipherRiskService.computeRiskForCiphers(
[loginCipher, cardCipher, identityCipher],
mockUserId,
);
expect(mockCipherRiskClient.compute_risk).toHaveBeenCalledWith(
[
expect.objectContaining({
id: expect.anything(),
password: "password1",
}),
],
expect.any(Object),
);
});
it("should filter out Login ciphers without passwords", async () => {
const mockClient = sdkService.simulate.userLogin(mockUserId);
const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep();
mockCipherRiskClient.compute_risk.mockResolvedValue([]);
const cipherWithPassword = new CipherView();
cipherWithPassword.id = mockCipherId1;
cipherWithPassword.type = CipherType.Login;
cipherWithPassword.login = new LoginView();
cipherWithPassword.login.password = "password1";
const cipherWithoutPassword = new CipherView();
cipherWithoutPassword.id = mockCipherId2;
cipherWithoutPassword.type = CipherType.Login;
cipherWithoutPassword.login = new LoginView();
cipherWithoutPassword.login.password = undefined;
const cipherWithEmptyPassword = new CipherView();
cipherWithEmptyPassword.id = mockCipherId3;
cipherWithEmptyPassword.type = CipherType.Login;
cipherWithEmptyPassword.login = new LoginView();
cipherWithEmptyPassword.login.password = "";
await cipherRiskService.computeRiskForCiphers(
[cipherWithPassword, cipherWithoutPassword, cipherWithEmptyPassword],
mockUserId,
);
expect(mockCipherRiskClient.compute_risk).toHaveBeenCalledWith(
[
expect.objectContaining({
password: "password1",
}),
],
expect.any(Object),
);
});
it("should return empty array when no valid Login ciphers provided", async () => {
const cardCipher = new CipherView();
cardCipher.type = CipherType.Card;
const results = await cipherRiskService.computeRiskForCiphers([cardCipher], mockUserId);
expect(results).toEqual([]);
});
it("should handle multiple Login ciphers", async () => {
const mockClient = sdkService.simulate.userLogin(mockUserId);
const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep();
const mockRiskResults: CipherRiskResult[] = [
{
id: mockCipherId1 as any,
password_strength: 3,
exposed_result: { type: "Found", value: 5 },
reuse_count: 2,
},
{
id: mockCipherId2 as any,
password_strength: 4,
exposed_result: { type: "NotChecked" },
reuse_count: 1,
},
];
mockCipherRiskClient.compute_risk.mockResolvedValue(mockRiskResults);
const cipher1 = new CipherView();
cipher1.id = mockCipherId1;
cipher1.type = CipherType.Login;
cipher1.login = new LoginView();
cipher1.login.password = "password1";
cipher1.login.username = "user1@example.com";
const cipher2 = new CipherView();
cipher2.id = mockCipherId2;
cipher2.type = CipherType.Login;
cipher2.login = new LoginView();
cipher2.login.password = "password2";
cipher2.login.username = "user2@example.com";
const results = await cipherRiskService.computeRiskForCiphers([cipher1, cipher2], mockUserId);
expect(mockCipherRiskClient.compute_risk).toHaveBeenCalledWith(
[
expect.objectContaining({ password: "password1", username: "user1@example.com" }),
expect.objectContaining({ password: "password2", username: "user2@example.com" }),
],
expect.any(Object),
);
expect(results).toEqual(mockRiskResults);
});
it("should use default options when options not provided", async () => {
const mockClient = sdkService.simulate.userLogin(mockUserId);
const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep();
mockCipherRiskClient.compute_risk.mockResolvedValue([]);
const cipher = new CipherView();
cipher.id = mockCipherId1;
cipher.type = CipherType.Login;
cipher.login = new LoginView();
cipher.login.password = "test-password";
await cipherRiskService.computeRiskForCiphers([cipher], mockUserId);
expect(mockCipherRiskClient.compute_risk).toHaveBeenCalledWith(expect.any(Array), {
checkExposed: false,
passwordMap: undefined,
hibpBaseUrl: undefined,
});
});
it("should handle ciphers without username", async () => {
const mockClient = sdkService.simulate.userLogin(mockUserId);
const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep();
mockCipherRiskClient.compute_risk.mockResolvedValue([]);
const cipher = new CipherView();
cipher.id = mockCipherId1;
cipher.type = CipherType.Login;
cipher.login = new LoginView();
cipher.login.password = "test-password";
cipher.login.username = undefined;
await cipherRiskService.computeRiskForCiphers([cipher], mockUserId);
expect(mockCipherRiskClient.compute_risk).toHaveBeenCalledWith(
[
expect.objectContaining({
password: "test-password",
username: undefined,
}),
],
expect.any(Object),
);
});
});
describe("buildPasswordReuseMap", () => {
it("should call SDK cipher_risk().password_reuse_map() with correct parameters", async () => {
const mockClient = sdkService.simulate.userLogin(mockUserId);
const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep();
const mockReuseMap = {
password1: 2,
password2: 1,
};
mockCipherRiskClient.password_reuse_map.mockReturnValue(mockReuseMap);
const cipher1 = new CipherView();
cipher1.id = mockCipherId1;
cipher1.type = CipherType.Login;
cipher1.login = new LoginView();
cipher1.login.password = "password1";
const cipher2 = new CipherView();
cipher2.id = mockCipherId2;
cipher2.type = CipherType.Login;
cipher2.login = new LoginView();
cipher2.login.password = "password2";
const result = await cipherRiskService.buildPasswordReuseMap([cipher1, cipher2], mockUserId);
expect(mockCipherRiskClient.password_reuse_map).toHaveBeenCalledWith([
expect.objectContaining({ password: "password1" }),
expect.objectContaining({ password: "password2" }),
]);
expect(result).toEqual(mockReuseMap);
});
});
describe("computeCipherRiskForUser", () => {
it("should compute risk for a single cipher with password reuse map", async () => {
const mockClient = sdkService.simulate.userLogin(mockUserId);
const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep();
// Setup cipher data
const cipher1 = new CipherView();
cipher1.id = mockCipherId1;
cipher1.type = CipherType.Login;
cipher1.login = new LoginView();
cipher1.login.password = "password1";
cipher1.login.username = "user1@example.com";
const cipher2 = new CipherView();
cipher2.id = mockCipherId2;
cipher2.type = CipherType.Login;
cipher2.login = new LoginView();
cipher2.login.password = "password1"; // Same password as cipher1
cipher2.login.username = "user2@example.com";
const allCiphers = [cipher1, cipher2];
// Mock cipherViews$ observable
mockCipherService.cipherViews$.mockReturnValue(new BehaviorSubject(allCiphers));
// Mock password reuse map
const mockReuseMap = { password1: 2 };
mockCipherRiskClient.password_reuse_map.mockReturnValue(mockReuseMap);
// Mock compute_risk result
const mockRiskResult: CipherRiskResult = {
id: mockCipherId1 as any,
password_strength: 3,
exposed_result: { type: "NotChecked" },
reuse_count: 2,
};
mockCipherRiskClient.compute_risk.mockResolvedValue([mockRiskResult]);
const result = await cipherRiskService.computeCipherRiskForUser(
asUuid<CipherId>(mockCipherId1),
mockUserId,
true,
);
// Verify cipherViews$ was called
expect(mockCipherService.cipherViews$).toHaveBeenCalledWith(mockUserId);
// Verify password_reuse_map was called with all ciphers
expect(mockCipherRiskClient.password_reuse_map).toHaveBeenCalledWith([
expect.objectContaining({ password: "password1", username: "user1@example.com" }),
expect.objectContaining({ password: "password1", username: "user2@example.com" }),
]);
// Verify compute_risk was called with target cipher and password map
expect(mockCipherRiskClient.compute_risk).toHaveBeenCalledWith(
[expect.objectContaining({ password: "password1", username: "user1@example.com" })],
{
passwordMap: mockReuseMap,
checkExposed: true,
},
);
expect(result).toEqual(mockRiskResult);
});
it("should throw error when cipher is not found", async () => {
const cipher1 = new CipherView();
cipher1.id = mockCipherId1;
cipher1.type = CipherType.Login;
cipher1.login = new LoginView();
cipher1.login.password = "password1";
mockCipherService.cipherViews$.mockReturnValue(new BehaviorSubject([cipher1]));
const nonExistentId = "00000000-0000-0000-0000-000000000000";
await expect(
cipherRiskService.computeCipherRiskForUser(asUuid<CipherId>(nonExistentId), mockUserId),
).rejects.toThrow(`Cipher with id ${asUuid<CipherId>(nonExistentId)} not found`);
});
it("should use checkExposed parameter correctly", async () => {
const mockClient = sdkService.simulate.userLogin(mockUserId);
const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep();
const cipher = new CipherView();
cipher.id = mockCipherId1;
cipher.type = CipherType.Login;
cipher.login = new LoginView();
cipher.login.password = "password1";
mockCipherService.cipherViews$.mockReturnValue(new BehaviorSubject([cipher]));
mockCipherRiskClient.password_reuse_map.mockReturnValue({});
mockCipherRiskClient.compute_risk.mockResolvedValue([
{
id: mockCipherId1 as any,
password_strength: 4,
exposed_result: { type: "NotChecked" },
reuse_count: 1,
},
]);
await cipherRiskService.computeCipherRiskForUser(
asUuid<CipherId>(mockCipherId1),
mockUserId,
false,
);
expect(mockCipherRiskClient.compute_risk).toHaveBeenCalledWith(expect.any(Array), {
passwordMap: expect.any(Object),
checkExposed: false,
});
});
it("should default checkExposed to true when not provided", async () => {
const mockClient = sdkService.simulate.userLogin(mockUserId);
const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep();
const cipher = new CipherView();
cipher.id = mockCipherId1;
cipher.type = CipherType.Login;
cipher.login = new LoginView();
cipher.login.password = "password1";
mockCipherService.cipherViews$.mockReturnValue(new BehaviorSubject([cipher]));
mockCipherRiskClient.password_reuse_map.mockReturnValue({});
mockCipherRiskClient.compute_risk.mockResolvedValue([
{
id: mockCipherId1 as any,
password_strength: 4,
exposed_result: { type: "Found", value: 10 },
reuse_count: 1,
},
]);
await cipherRiskService.computeCipherRiskForUser(asUuid<CipherId>(mockCipherId1), mockUserId);
expect(mockCipherRiskClient.compute_risk).toHaveBeenCalledWith(expect.any(Array), {
passwordMap: expect.any(Object),
checkExposed: true,
});
});
it("should handle ciphers without passwords when building password map", async () => {
const mockClient = sdkService.simulate.userLogin(mockUserId);
const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep();
const cipherWithPassword = new CipherView();
cipherWithPassword.id = mockCipherId1;
cipherWithPassword.type = CipherType.Login;
cipherWithPassword.login = new LoginView();
cipherWithPassword.login.password = "password1";
const cipherWithoutPassword = new CipherView();
cipherWithoutPassword.id = mockCipherId2;
cipherWithoutPassword.type = CipherType.Login;
cipherWithoutPassword.login = new LoginView();
cipherWithoutPassword.login.password = "";
mockCipherService.cipherViews$.mockReturnValue(
new BehaviorSubject([cipherWithPassword, cipherWithoutPassword]),
);
mockCipherRiskClient.password_reuse_map.mockReturnValue({});
mockCipherRiskClient.compute_risk.mockResolvedValue([
{
id: mockCipherId1 as any,
password_strength: 4,
exposed_result: { type: "NotChecked" },
reuse_count: 1,
},
]);
await cipherRiskService.computeCipherRiskForUser(asUuid<CipherId>(mockCipherId1), mockUserId);
// Verify password_reuse_map only received cipher with password
expect(mockCipherRiskClient.password_reuse_map).toHaveBeenCalledWith([
expect.objectContaining({ password: "password1" }),
]);
});
it("should handle non-Login ciphers in vault when building password map", async () => {
const mockClient = sdkService.simulate.userLogin(mockUserId);
const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep();
const loginCipher = new CipherView();
loginCipher.id = mockCipherId1;
loginCipher.type = CipherType.Login;
loginCipher.login = new LoginView();
loginCipher.login.password = "password1";
const cardCipher = new CipherView();
cardCipher.id = mockCipherId2;
cardCipher.type = CipherType.Card;
const noteCipher = new CipherView();
noteCipher.id = mockCipherId3;
noteCipher.type = CipherType.SecureNote;
mockCipherService.cipherViews$.mockReturnValue(
new BehaviorSubject([loginCipher, cardCipher, noteCipher]),
);
mockCipherRiskClient.password_reuse_map.mockReturnValue({});
mockCipherRiskClient.compute_risk.mockResolvedValue([
{
id: mockCipherId1 as any,
password_strength: 4,
exposed_result: { type: "NotChecked" },
reuse_count: 1,
},
]);
await cipherRiskService.computeCipherRiskForUser(asUuid<CipherId>(mockCipherId1), mockUserId);
// Verify password_reuse_map only received Login cipher
expect(mockCipherRiskClient.password_reuse_map).toHaveBeenCalledWith([
expect.objectContaining({ password: "password1" }),
]);
});
it("should compute fresh password map on each call", async () => {
const mockClient = sdkService.simulate.userLogin(mockUserId);
const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep();
const cipher = new CipherView();
cipher.id = mockCipherId1;
cipher.type = CipherType.Login;
cipher.login = new LoginView();
cipher.login.password = "password1";
mockCipherService.cipherViews$.mockReturnValue(new BehaviorSubject([cipher]));
mockCipherRiskClient.password_reuse_map.mockReturnValue({ password1: 1 });
mockCipherRiskClient.compute_risk.mockResolvedValue([
{
id: mockCipherId1 as any,
password_strength: 4,
exposed_result: { type: "NotChecked" },
reuse_count: 1,
},
]);
// First call
await cipherRiskService.computeCipherRiskForUser(asUuid<CipherId>(mockCipherId1), mockUserId);
// Second call
await cipherRiskService.computeCipherRiskForUser(asUuid<CipherId>(mockCipherId1), mockUserId);
// Verify password_reuse_map was called twice (fresh computation each time)
expect(mockCipherRiskClient.password_reuse_map).toHaveBeenCalledTimes(2);
});
});
});

View File

@@ -0,0 +1,115 @@
import { firstValueFrom, switchMap } from "rxjs";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import {
CipherLoginDetails,
CipherRiskOptions,
PasswordReuseMap,
CipherId,
CipherRiskResult,
} from "@bitwarden/sdk-internal";
import { SdkService, asUuid } from "../../platform/abstractions/sdk/sdk.service";
import { UserId } from "../../types/guid";
import { CipherRiskService as CipherRiskServiceAbstraction } from "../abstractions/cipher-risk.service";
import { CipherType } from "../enums/cipher-type";
import { CipherView } from "../models/view/cipher.view";
export class DefaultCipherRiskService implements CipherRiskServiceAbstraction {
constructor(
private sdkService: SdkService,
private cipherService: CipherService,
) {}
async computeRiskForCiphers(
ciphers: CipherView[],
userId: UserId,
options?: CipherRiskOptions,
): Promise<CipherRiskResult[]> {
const loginDetails = this.mapToLoginDetails(ciphers);
if (loginDetails.length === 0) {
return [];
}
return await firstValueFrom(
this.sdkService.userClient$(userId).pipe(
switchMap(async (sdk) => {
using ref = sdk.take();
const cipherRiskClient = ref.value.vault().cipher_risk();
return await cipherRiskClient.compute_risk(
loginDetails,
options ?? { checkExposed: false },
);
}),
),
);
}
async computeCipherRiskForUser(
cipherId: CipherId,
userId: UserId,
checkExposed: boolean = true,
): Promise<CipherRiskResult> {
// Get all ciphers for the user
const allCiphers = await firstValueFrom(this.cipherService.cipherViews$(userId));
// Find the specific cipher
const targetCipher = allCiphers?.find((c) => asUuid<CipherId>(c.id) === cipherId);
if (!targetCipher) {
throw new Error(`Cipher with id ${cipherId} not found`);
}
// Build fresh password reuse map from all ciphers
const passwordMap = await this.buildPasswordReuseMap(allCiphers, userId);
// Call existing computeRiskForCiphers with single cipher and map
const results = await this.computeRiskForCiphers([targetCipher], userId, {
passwordMap,
checkExposed,
});
return results[0];
}
async buildPasswordReuseMap(ciphers: CipherView[], userId: UserId): Promise<PasswordReuseMap> {
const loginDetails = this.mapToLoginDetails(ciphers);
if (loginDetails.length === 0) {
return {};
}
return await firstValueFrom(
this.sdkService.userClient$(userId).pipe(
switchMap(async (sdk) => {
using ref = sdk.take();
const cipherRiskClient = ref.value.vault().cipher_risk();
return cipherRiskClient.password_reuse_map(loginDetails);
}),
),
);
}
/**
* Maps CipherView array to CipherLoginDetails array for SDK consumption.
* Only includes Login ciphers with non-empty passwords.
*/
private mapToLoginDetails(ciphers: CipherView[]): CipherLoginDetails[] {
return ciphers
.filter((cipher) => {
return (
cipher.type === CipherType.Login &&
cipher.login?.password != null &&
cipher.login.password !== ""
);
})
.map(
(cipher) =>
({
id: asUuid<CipherId>(cipher.id),
password: cipher.login.password!,
username: cipher.login.username,
}) satisfies CipherLoginDetails,
);
}
}