1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-15 07:54:55 +00:00

Merge branch 'main' into tools/pm-30675/account-restricted-export-incorrect-data

This commit is contained in:
Mike Amirault
2026-01-26 10:20:09 -05:00
committed by GitHub
207 changed files with 4245 additions and 753 deletions

View File

@@ -886,7 +886,7 @@ const safeProviders: SafeProvider[] = [
FolderApiServiceAbstraction,
InternalOrganizationServiceAbstraction,
SendApiServiceAbstraction,
UserDecryptionOptionsServiceAbstraction,
InternalUserDecryptionOptionsServiceAbstraction,
AvatarServiceAbstraction,
LOGOUT_CALLBACK,
BillingAccountProfileStateService,

View File

@@ -194,12 +194,7 @@ export class VaultItemsComponent<C extends CipherViewLike> implements OnDestroy
return this.searchService.searchCiphers(
userId,
searchText,
[
filter,
this.deletedFilter,
...(this.deleted ? [] : [this.archivedFilter]),
restrictedTypeFilter,
],
[filter, restrictedTypeFilter],
allCiphers,
);
}),

View File

@@ -54,6 +54,12 @@ export class VaultFilter {
cipherPassesFilter =
CipherViewLikeUtils.isArchived(cipher) && !CipherViewLikeUtils.isDeleted(cipher);
}
if (this.status !== "archive" && this.status !== "trash" && cipherPassesFilter) {
cipherPassesFilter =
!CipherViewLikeUtils.isArchived(cipher) && !CipherViewLikeUtils.isDeleted(cipher);
}
if (this.cipherType != null && cipherPassesFilter) {
cipherPassesFilter = CipherViewLikeUtils.getType(cipher) === this.cipherType;
}

View File

@@ -175,6 +175,8 @@ describe("WebAuthnLoginStrategy", () => {
WebAuthnPrfOption: {
EncryptedPrivateKey: mockEncPrfPrivateKey,
EncryptedUserKey: mockEncUserKey,
CredentialId: "mockCredentialId",
Transports: ["usb", "nfc"],
},
};

View File

@@ -73,14 +73,15 @@ export class WebAuthnLoginStrategy extends LoginStrategy {
const userDecryptionOptions = idTokenResponse?.userDecryptionOptions;
if (userDecryptionOptions?.webAuthnPrfOption) {
const webAuthnPrfOption = idTokenResponse.userDecryptionOptions?.webAuthnPrfOption;
const credentials = this.cache.value.credentials;
// confirm we still have the prf key
if (!credentials.prfKey) {
return;
}
const webAuthnPrfOption = userDecryptionOptions.webAuthnPrfOption;
// decrypt prf encrypted private key
const privateKey = await this.encryptService.unwrapDecapsulationKey(
webAuthnPrfOption.encryptedPrivateKey,

View File

@@ -5,6 +5,7 @@ import { Jsonify } from "type-fest";
import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response";
import { KeyConnectorUserDecryptionOptionResponse } from "@bitwarden/common/auth/models/response/user-decryption-options/key-connector-user-decryption-option.response";
import { TrustedDeviceUserDecryptionOptionResponse } from "@bitwarden/common/auth/models/response/user-decryption-options/trusted-device-user-decryption-option.response";
import { WebAuthnPrfDecryptionOptionResponse } from "@bitwarden/common/auth/models/response/user-decryption-options/webauthn-prf-decryption-option.response";
/**
* Key Connector decryption options. Intended to be sent to the client for use after authentication.
@@ -45,6 +46,61 @@ export class KeyConnectorUserDecryptionOption {
}
}
/**
* Trusted device decryption options. Intended to be sent to the client for use after authentication.
* @see {@link UserDecryptionOptions}
*/
/**
* WebAuthn PRF decryption options. Intended to be sent to the client for use after authentication.
* @see {@link UserDecryptionOptions}
*/
export class WebAuthnPrfUserDecryptionOption {
/** The encrypted private key that can be decrypted with the PRF key. */
encryptedPrivateKey: string;
/** The encrypted user key that can be decrypted with the private key. */
encryptedUserKey: string;
/** The credential ID for this WebAuthn PRF credential. */
credentialId: string;
/** The transports supported by this credential. */
transports: string[];
/**
* Initializes a new instance of the WebAuthnPrfUserDecryptionOption from a response object.
* @param response The WebAuthn PRF user decryption option response object.
* @returns A new instance of the WebAuthnPrfUserDecryptionOption or undefined if `response` is nullish.
*/
static fromResponse(
response: WebAuthnPrfDecryptionOptionResponse,
): WebAuthnPrfUserDecryptionOption | undefined {
if (response == null) {
return undefined;
}
if (!response.encryptedPrivateKey || !response.encryptedUserKey) {
return undefined;
}
const options = new WebAuthnPrfUserDecryptionOption();
options.encryptedPrivateKey = response.encryptedPrivateKey.encryptedString;
options.encryptedUserKey = response.encryptedUserKey.encryptedString;
options.credentialId = response.credentialId;
options.transports = response.transports || [];
return options;
}
/**
* Initializes a new instance of a WebAuthnPrfUserDecryptionOption from a JSON object.
* @param obj JSON object to deserialize.
* @returns A new instance of the WebAuthnPrfUserDecryptionOption or undefined if `obj` is nullish.
*/
static fromJSON(
obj: Jsonify<WebAuthnPrfUserDecryptionOption>,
): WebAuthnPrfUserDecryptionOption | undefined {
if (obj == null) {
return undefined;
}
return Object.assign(new WebAuthnPrfUserDecryptionOption(), obj);
}
}
/**
* Trusted device decryption options. Intended to be sent to the client for use after authentication.
* @see {@link UserDecryptionOptions}
@@ -104,6 +160,8 @@ export class UserDecryptionOptions {
trustedDeviceOption?: TrustedDeviceUserDecryptionOption;
/** {@link KeyConnectorUserDecryptionOption} */
keyConnectorOption?: KeyConnectorUserDecryptionOption;
/** Array of {@link WebAuthnPrfUserDecryptionOption} */
webAuthnPrfOptions?: WebAuthnPrfUserDecryptionOption[];
/**
* Initializes a new instance of the UserDecryptionOptions from a response object.
@@ -134,6 +192,18 @@ export class UserDecryptionOptions {
decryptionOptions.keyConnectorOption = KeyConnectorUserDecryptionOption.fromResponse(
responseOptions.keyConnectorOption,
);
// The IdTokenResponse only returns a single WebAuthn PRF option to support immediate unlock after logging in
// with the same PRF passkey.
// Since our domain model supports multiple WebAuthn PRF options, we convert the single option into an array.
if (responseOptions.webAuthnPrfOption) {
const option = WebAuthnPrfUserDecryptionOption.fromResponse(
responseOptions.webAuthnPrfOption,
);
if (option) {
decryptionOptions.webAuthnPrfOptions = [option];
}
}
} else {
throw new Error(
"User Decryption Options are required for client initialization. userDecryptionOptions is missing in response.",
@@ -158,6 +228,12 @@ export class UserDecryptionOptions {
obj?.keyConnectorOption,
);
if (obj?.webAuthnPrfOptions && Array.isArray(obj.webAuthnPrfOptions)) {
decryptionOptions.webAuthnPrfOptions = obj.webAuthnPrfOptions
.map((option) => WebAuthnPrfUserDecryptionOption.fromJSON(option))
.filter((option) => option !== undefined);
}
return decryptionOptions;
}
}

View File

@@ -27,6 +27,10 @@ export class UserDecryptionOptionsResponse extends BaseResponse {
masterPasswordUnlock?: MasterPasswordUnlockResponse;
trustedDeviceOption?: TrustedDeviceUserDecryptionOptionResponse;
keyConnectorOption?: KeyConnectorUserDecryptionOptionResponse;
/**
* The IdTokenresponse only returns a single WebAuthn PRF option.
* To support immediate unlock after logging in with the same PRF passkey.
*/
webAuthnPrfOption?: WebAuthnPrfDecryptionOptionResponse;
constructor(response: IUserDecryptionOptionsServerResponse) {

View File

@@ -6,19 +6,30 @@ import { BaseResponse } from "../../../../models/response/base.response";
export interface IWebAuthnPrfDecryptionOptionServerResponse {
EncryptedPrivateKey: string;
EncryptedUserKey: string;
CredentialId: string;
Transports: string[];
}
export class WebAuthnPrfDecryptionOptionResponse extends BaseResponse {
encryptedPrivateKey: EncString;
encryptedUserKey: EncString;
credentialId: string;
transports: string[];
constructor(response: IWebAuthnPrfDecryptionOptionServerResponse) {
super(response);
if (response.EncryptedPrivateKey) {
this.encryptedPrivateKey = new EncString(this.getResponseProperty("EncryptedPrivateKey"));
const encPrivateKey = this.getResponseProperty("EncryptedPrivateKey");
if (encPrivateKey) {
this.encryptedPrivateKey = new EncString(encPrivateKey);
}
if (response.EncryptedUserKey) {
this.encryptedUserKey = new EncString(this.getResponseProperty("EncryptedUserKey"));
const encUserKey = this.getResponseProperty("EncryptedUserKey");
if (encUserKey) {
this.encryptedUserKey = new EncString(encUserKey);
}
this.credentialId = this.getResponseProperty("CredentialId");
this.transports = this.getResponseProperty("Transports") || [];
}
}

View File

@@ -42,6 +42,7 @@ export enum FeatureFlag {
ForceUpdateKDFSettings = "pm-18021-force-update-kdf-settings",
LinuxBiometricsV2 = "pm-26340-linux-biometrics-v2",
NoLogoutOnKdfChange = "pm-23995-no-logout-on-kdf-change",
PasskeyUnlock = "pm-2035-passkey-unlock",
DataRecoveryTool = "pm-28813-data-recovery-tool",
ConsolidatedSessionTimeoutComponent = "pm-26056-consolidated-session-timeout-component",
PM27279_V2RegistrationTdeJit = "pm-27279-v2-registration-tde-jit",
@@ -56,6 +57,7 @@ export enum FeatureFlag {
/* DIRT */
EventManagementForDataDogAndCrowdStrike = "event-management-for-datadog-and-crowdstrike",
EventManagementForHuntress = "event-management-for-huntress",
PhishingDetection = "phishing-detection",
/* Vault */
@@ -119,6 +121,7 @@ export const DefaultFeatureFlagValue = {
/* DIRT */
[FeatureFlag.EventManagementForDataDogAndCrowdStrike]: FALSE,
[FeatureFlag.EventManagementForHuntress]: FALSE,
[FeatureFlag.PhishingDetection]: FALSE,
/* Vault */
@@ -151,6 +154,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.ForceUpdateKDFSettings]: FALSE,
[FeatureFlag.LinuxBiometricsV2]: FALSE,
[FeatureFlag.NoLogoutOnKdfChange]: FALSE,
[FeatureFlag.PasskeyUnlock]: FALSE,
[FeatureFlag.DataRecoveryTool]: FALSE,
[FeatureFlag.ConsolidatedSessionTimeoutComponent]: FALSE,
[FeatureFlag.PM27279_V2RegistrationTdeJit]: FALSE,

View File

@@ -1,9 +1,15 @@
import { WebAuthnPrfDecryptionOptionResponse } from "../../../auth/models/response/user-decryption-options/webauthn-prf-decryption-option.response";
import { BaseResponse } from "../../../models/response/base.response";
import { MasterPasswordUnlockResponse } from "../../master-password/models/response/master-password-unlock.response";
export class UserDecryptionResponse extends BaseResponse {
masterPasswordUnlock?: MasterPasswordUnlockResponse;
/**
* The sync service returns an array of WebAuthn PRF options.
*/
webAuthnPrfOptions?: WebAuthnPrfDecryptionOptionResponse[];
constructor(response: unknown) {
super(response);
@@ -11,5 +17,12 @@ export class UserDecryptionResponse extends BaseResponse {
if (masterPasswordUnlock != null && typeof masterPasswordUnlock === "object") {
this.masterPasswordUnlock = new MasterPasswordUnlockResponse(masterPasswordUnlock);
}
const webAuthnPrfOptions = this.getResponseProperty("WebAuthnPrfOptions");
if (webAuthnPrfOptions != null && Array.isArray(webAuthnPrfOptions)) {
this.webAuthnPrfOptions = webAuthnPrfOptions.map(
(option) => new WebAuthnPrfDecryptionOptionResponse(option),
);
}
}
}

View File

@@ -260,6 +260,13 @@ describe("VaultTimeoutSettingsService", () => {
});
describe("getVaultTimeoutByUserId$", () => {
beforeEach(() => {
// Return the input value unchanged
sessionTimeoutTypeService.getOrPromoteToAvailable.mockImplementation(
async (timeout) => timeout,
);
});
it("should throw an error if no user id is provided", async () => {
expect(() => vaultTimeoutSettingsService.getVaultTimeoutByUserId$(null)).toThrow(
"User id required. Cannot get vault timeout.",
@@ -277,6 +284,9 @@ describe("VaultTimeoutSettingsService", () => {
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
defaultVaultTimeout,
);
expect(result).toBe(defaultVaultTimeout);
});
@@ -299,8 +309,31 @@ describe("VaultTimeoutSettingsService", () => {
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
vaultTimeout,
);
expect(result).toBe(vaultTimeout);
});
it("promotes timeout when unavailable on client", async () => {
const determinedTimeout = VaultTimeoutNumberType.OnMinute;
const promotedValue = VaultTimeoutStringType.OnRestart;
sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(promotedValue);
userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true }));
policyService.policiesByType$.mockReturnValue(of([]));
await stateProvider.setUserState(VAULT_TIMEOUT, determinedTimeout, mockUserId);
const result = await firstValueFrom(
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
determinedTimeout,
);
expect(result).toBe(promotedValue);
});
});
describe("policy type: custom", () => {
@@ -327,6 +360,9 @@ describe("VaultTimeoutSettingsService", () => {
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
policyMinutes,
);
expect(result).toBe(policyMinutes);
},
);
@@ -345,6 +381,9 @@ describe("VaultTimeoutSettingsService", () => {
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
vaultTimeout,
);
expect(result).toBe(vaultTimeout);
},
);
@@ -365,8 +404,36 @@ describe("VaultTimeoutSettingsService", () => {
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
VaultTimeoutNumberType.Immediately,
);
expect(result).toBe(VaultTimeoutNumberType.Immediately);
});
it("promotes policy minutes when unavailable on client", async () => {
const promotedValue = VaultTimeoutStringType.Never;
sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(promotedValue);
userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true }));
policyService.policiesByType$.mockReturnValue(
of([{ data: { type: "custom", minutes: policyMinutes } }] as unknown as Policy[]),
);
await stateProvider.setUserState(
VAULT_TIMEOUT,
VaultTimeoutNumberType.EightHours,
mockUserId,
);
const result = await firstValueFrom(
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
policyMinutes,
);
expect(result).toBe(promotedValue);
});
});
describe("policy type: immediately", () => {
@@ -383,7 +450,6 @@ describe("VaultTimeoutSettingsService", () => {
"when current timeout is %s, returns immediately or promoted value",
async (currentTimeout) => {
const expectedTimeout = VaultTimeoutNumberType.Immediately;
sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(expectedTimeout);
policyService.policiesByType$.mockReturnValue(
of([{ data: { type: "immediately" } }] as unknown as Policy[]),
);
@@ -400,6 +466,26 @@ describe("VaultTimeoutSettingsService", () => {
expect(result).toBe(expectedTimeout);
},
);
it("promotes immediately when unavailable on client", async () => {
const promotedValue = VaultTimeoutNumberType.OnMinute;
sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(promotedValue);
policyService.policiesByType$.mockReturnValue(
of([{ data: { type: "immediately" } }] as unknown as Policy[]),
);
await stateProvider.setUserState(VAULT_TIMEOUT, VaultTimeoutStringType.Never, mockUserId);
const result = await firstValueFrom(
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
VaultTimeoutNumberType.Immediately,
);
expect(result).toBe(promotedValue);
});
});
describe("policy type: onSystemLock", () => {
@@ -413,7 +499,6 @@ describe("VaultTimeoutSettingsService", () => {
"when current timeout is %s, returns onLocked or promoted value",
async (currentTimeout) => {
const expectedTimeout = VaultTimeoutStringType.OnLocked;
sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(expectedTimeout);
policyService.policiesByType$.mockReturnValue(
of([{ data: { type: "onSystemLock" } }] as unknown as Policy[]),
);
@@ -446,9 +531,31 @@ describe("VaultTimeoutSettingsService", () => {
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).not.toHaveBeenCalled();
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
currentTimeout,
);
expect(result).toBe(currentTimeout);
});
it("promotes onLocked when unavailable on client", async () => {
const promotedValue = VaultTimeoutStringType.OnRestart;
sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(promotedValue);
policyService.policiesByType$.mockReturnValue(
of([{ data: { type: "onSystemLock" } }] as unknown as Policy[]),
);
await stateProvider.setUserState(VAULT_TIMEOUT, VaultTimeoutStringType.Never, mockUserId);
const result = await firstValueFrom(
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
VaultTimeoutStringType.OnLocked,
);
expect(result).toBe(promotedValue);
});
});
describe("policy type: onAppRestart", () => {
@@ -468,7 +575,9 @@ describe("VaultTimeoutSettingsService", () => {
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).not.toHaveBeenCalled();
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
VaultTimeoutStringType.OnRestart,
);
expect(result).toBe(VaultTimeoutStringType.OnRestart);
});
@@ -488,32 +597,40 @@ describe("VaultTimeoutSettingsService", () => {
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).not.toHaveBeenCalled();
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
currentTimeout,
);
expect(result).toBe(currentTimeout);
});
});
describe("policy type: never", () => {
it("when current timeout is never, returns never or promoted value", async () => {
const expectedTimeout = VaultTimeoutStringType.Never;
sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(expectedTimeout);
it("promotes onRestart when unavailable on client", async () => {
const promotedValue = VaultTimeoutStringType.Never;
sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(promotedValue);
policyService.policiesByType$.mockReturnValue(
of([{ data: { type: "never" } }] as unknown as Policy[]),
of([{ data: { type: "onAppRestart" } }] as unknown as Policy[]),
);
await stateProvider.setUserState(VAULT_TIMEOUT, VaultTimeoutStringType.Never, mockUserId);
await stateProvider.setUserState(
VAULT_TIMEOUT,
VaultTimeoutStringType.OnLocked,
mockUserId,
);
const result = await firstValueFrom(
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
VaultTimeoutStringType.Never,
VaultTimeoutStringType.OnRestart,
);
expect(result).toBe(expectedTimeout);
expect(result).toBe(promotedValue);
});
});
describe("policy type: never", () => {
it.each([
VaultTimeoutStringType.Never,
VaultTimeoutStringType.OnRestart,
VaultTimeoutStringType.OnLocked,
VaultTimeoutStringType.OnIdle,
@@ -532,9 +649,32 @@ describe("VaultTimeoutSettingsService", () => {
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).not.toHaveBeenCalled();
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
currentTimeout,
);
expect(result).toBe(currentTimeout);
});
it("promotes timeout when unavailable on client", async () => {
const determinedTimeout = VaultTimeoutStringType.Never;
const promotedValue = VaultTimeoutStringType.OnRestart;
sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(promotedValue);
policyService.policiesByType$.mockReturnValue(
of([{ data: { type: "never" } }] as unknown as Policy[]),
);
await stateProvider.setUserState(VAULT_TIMEOUT, determinedTimeout, mockUserId);
const result = await firstValueFrom(
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
determinedTimeout,
);
expect(result).toBe(promotedValue);
});
});
});

View File

@@ -179,7 +179,20 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
private async determineVaultTimeout(
currentVaultTimeout: VaultTimeout | null,
maxSessionTimeoutPolicyData: MaximumSessionTimeoutPolicyData | null,
): Promise<VaultTimeout | null> {
): Promise<VaultTimeout> {
const determinedTimeout = await this.determineVaultTimeoutInternal(
currentVaultTimeout,
maxSessionTimeoutPolicyData,
);
// Ensures the timeout is available on this client
return await this.sessionTimeoutTypeService.getOrPromoteToAvailable(determinedTimeout);
}
private async determineVaultTimeoutInternal(
currentVaultTimeout: VaultTimeout | null,
maxSessionTimeoutPolicyData: MaximumSessionTimeoutPolicyData | null,
): Promise<VaultTimeout> {
// if current vault timeout is null, apply the client specific default
currentVaultTimeout = currentVaultTimeout ?? this.defaultVaultTimeout;
@@ -190,9 +203,7 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
switch (maxSessionTimeoutPolicyData.type) {
case "immediately":
return await this.sessionTimeoutTypeService.getOrPromoteToAvailable(
VaultTimeoutNumberType.Immediately,
);
return VaultTimeoutNumberType.Immediately;
case "custom":
case null:
case undefined:
@@ -211,9 +222,7 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
currentVaultTimeout === VaultTimeoutStringType.OnIdle ||
currentVaultTimeout === VaultTimeoutStringType.OnSleep
) {
return await this.sessionTimeoutTypeService.getOrPromoteToAvailable(
VaultTimeoutStringType.OnLocked,
);
return VaultTimeoutStringType.OnLocked;
}
break;
case "onAppRestart":
@@ -227,11 +236,7 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
}
break;
case "never":
if (currentVaultTimeout === VaultTimeoutStringType.Never) {
return await this.sessionTimeoutTypeService.getOrPromoteToAvailable(
VaultTimeoutStringType.Never,
);
}
// Policy doesn't override user preference for "never"
break;
}
return currentVaultTimeout;

View File

@@ -42,6 +42,7 @@ export class Utils {
static readonly validHosts: string[] = ["localhost"];
static readonly originalMinimumPasswordLength = 8;
static readonly minimumPasswordLength = 12;
static readonly maximumPasswordLength = 128;
static readonly DomainMatchBlacklist = new Map<string, Set<string>>([
["google.com", new Set(["script.google.com"])],
]);

View File

@@ -9,7 +9,7 @@ import { CollectionService } from "@bitwarden/admin-console/common";
import {
LogoutReason,
UserDecryptionOptions,
UserDecryptionOptionsServiceAbstraction,
InternalUserDecryptionOptionsServiceAbstraction,
} from "@bitwarden/auth/common";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
@@ -68,7 +68,7 @@ describe("DefaultSyncService", () => {
let folderApiService: MockProxy<FolderApiServiceAbstraction>;
let organizationService: MockProxy<InternalOrganizationServiceAbstraction>;
let sendApiService: MockProxy<SendApiService>;
let userDecryptionOptionsService: MockProxy<UserDecryptionOptionsServiceAbstraction>;
let userDecryptionOptionsService: MockProxy<InternalUserDecryptionOptionsServiceAbstraction>;
let avatarService: MockProxy<AvatarService>;
let logoutCallback: jest.Mock<Promise<void>, [logoutReason: LogoutReason, userId?: UserId]>;
let billingAccountProfileStateService: MockProxy<BillingAccountProfileStateService>;

View File

@@ -6,8 +6,8 @@ import { firstValueFrom, map } from "rxjs";
// eslint-disable-next-line no-restricted-imports
import { CollectionService } from "@bitwarden/admin-console/common";
import {
CollectionDetailsResponse,
CollectionData,
CollectionDetailsResponse,
} from "@bitwarden/common/admin-console/models/collections";
import { AccountCryptographicStateService } from "@bitwarden/common/key-management/account-cryptography/account-cryptographic-state.service";
import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service";
@@ -15,9 +15,13 @@ import { SecurityStateService } from "@bitwarden/common/key-management/security-
// eslint-disable-next-line no-restricted-imports
import { KdfConfigService, KeyService } from "@bitwarden/key-management";
// FIXME: remove `src` and fix import
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { UserDecryptionOptionsServiceAbstraction } from "../../../../auth/src/common/abstractions";
import {
InternalUserDecryptionOptionsServiceAbstraction,
UserDecryptionOptions,
WebAuthnPrfUserDecryptionOption,
} from "../../../../auth/src/common";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { LogoutReason } from "../../../../auth/src/common/types";
@@ -93,7 +97,7 @@ export class DefaultSyncService extends CoreSyncService {
folderApiService: FolderApiServiceAbstraction,
private organizationService: InternalOrganizationServiceAbstraction,
sendApiService: SendApiService,
private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction,
private userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction,
private avatarService: AvatarService,
private logoutCallback: (logoutReason: LogoutReason, userId?: UserId) => Promise<void>,
private billingAccountProfileStateService: BillingAccountProfileStateService,
@@ -450,5 +454,43 @@ export class DefaultSyncService extends CoreSyncService {
);
await this.kdfConfigService.setKdfConfig(userId, masterPasswordUnlockData.kdf);
}
// Update WebAuthn PRF options if present
if (userDecryption.webAuthnPrfOptions != null && userDecryption.webAuthnPrfOptions.length > 0) {
try {
// Only update if this is the active user, since setUserDecryptionOptions()
// operates on the active user's state
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
if (activeAccount?.id !== userId) {
return;
}
// Get current options without blocking if they don't exist yet
const currentUserDecryptionOptions = await firstValueFrom(
this.userDecryptionOptionsService.userDecryptionOptionsById$(userId),
).catch((): UserDecryptionOptions | null => {
return null;
});
if (currentUserDecryptionOptions != null) {
// Update the PRF options while preserving other decryption options
const updatedOptions = Object.assign(
new UserDecryptionOptions(),
currentUserDecryptionOptions,
);
updatedOptions.webAuthnPrfOptions = userDecryption.webAuthnPrfOptions
.map((option) => WebAuthnPrfUserDecryptionOption.fromResponse(option))
.filter((option) => option !== undefined);
await this.userDecryptionOptionsService.setUserDecryptionOptionsById(
activeAccount.id,
updatedOptions,
);
}
} catch (error) {
this.logService.error("[Sync] Failed to update WebAuthn PRF options:", error);
}
}
}
}

View File

@@ -27,7 +27,7 @@
<!-- 53rem = ~850px -->
<!-- This is a magic number. This number was selected by going to the UI and finding the number that felt the best to me and design. No real rhyme or reason :) -->
<div
class="[@media(min-height:53rem)]:tw-sticky tw-bottom-0 tw-left-0 tw-z-20 tw-mt-auto tw-w-full"
class="[@media(min-height:53rem)]:tw-sticky tw-bottom-0 tw-left-0 tw-z-20 tw-mt-auto tw-w-full tw-bg-bg-sidenav"
>
<bit-nav-divider></bit-nav-divider>
@if (data.open) {

View File

@@ -4,6 +4,8 @@
export { LockComponent } from "./lock/components/lock.component";
export { LockComponentService, UnlockOptions } from "./lock/services/lock-component.service";
export { WebAuthnPrfUnlockService } from "./lock/services/webauthn-prf-unlock.service";
export { DefaultWebAuthnPrfUnlockService } from "./lock/services/default-webauthn-prf-unlock.service";
export { KeyRotationTrustInfoComponent } from "./key-rotation/key-rotation-trust-info.component";
export { AccountRecoveryTrustComponent } from "./trust/account-recovery-trust.component";
export { EmergencyAccessTrustComponent } from "./trust/emergency-access-trust.component";

View File

@@ -49,6 +49,8 @@
</button>
</ng-container>
<bit-unlock-via-prf (unlockSuccess)="onPrfUnlockSuccess($event)"></bit-unlock-via-prf>
<button type="button" bitButton block (click)="logOut()">
{{ "logOut" | i18n }}
</button>
@@ -113,6 +115,11 @@
</button>
</ng-container>
<bit-unlock-via-prf
[formButton]="true"
(unlockSuccess)="onPrfUnlockSuccess($event)"
></bit-unlock-via-prf>
<button type="button" bitButton bitFormButton block (click)="logOut()">
{{ "logOut" | i18n }}
</button>
@@ -127,6 +134,7 @@
[unlockOptions]="unlockOptions"
[biometricUnlockBtnText]="biometricUnlockBtnText"
(successfulUnlock)="successfulMasterPasswordUnlock($event)"
(prfUnlockSuccess)="onPrfUnlockSuccess($event)"
(logOut)="logOut()"
></bit-master-password-lock>
}

View File

@@ -51,6 +51,7 @@ import {
UnlockOptionValue,
UnlockOptions,
} from "../services/lock-component.service";
import { WebAuthnPrfUnlockService } from "../services/webauthn-prf-unlock.service";
import { LockComponent } from "./lock.component";
@@ -84,6 +85,7 @@ describe("LockComponent", () => {
const mockLockComponentService = mock<LockComponentService>();
const mockAnonLayoutWrapperDataService = mock<AnonLayoutWrapperDataService>();
const mockBroadcasterService = mock<BroadcasterService>();
const mockWebAuthnPrfUnlockService = mock<WebAuthnPrfUnlockService>();
const mockEncryptedMigrator = mock<EncryptedMigrator>();
const mockActivatedRoute = {
snapshot: {
@@ -149,6 +151,7 @@ describe("LockComponent", () => {
{ provide: LockComponentService, useValue: mockLockComponentService },
{ provide: AnonLayoutWrapperDataService, useValue: mockAnonLayoutWrapperDataService },
{ provide: BroadcasterService, useValue: mockBroadcasterService },
{ provide: WebAuthnPrfUnlockService, useValue: mockWebAuthnPrfUnlockService },
{ provide: ActivatedRoute, useValue: mockActivatedRoute },
{ provide: EncryptedMigrator, useValue: mockEncryptedMigrator },
],

View File

@@ -60,6 +60,7 @@ import {
} from "../services/lock-component.service";
import { MasterPasswordLockComponent } from "./master-password-lock/master-password-lock.component";
import { UnlockViaPrfComponent } from "./unlock-via-prf.component";
const BroadcasterSubscriptionId = "LockComponent";
@@ -98,6 +99,7 @@ const BIOMETRIC_UNLOCK_TEMPORARY_UNAVAILABLE_STATUSES = [
FormFieldModule,
AsyncActionsModule,
IconButtonModule,
UnlockViaPrfComponent,
MasterPasswordLockComponent,
TooltipDirective,
],
@@ -460,6 +462,14 @@ export class LockComponent implements OnInit, OnDestroy {
}
}
async onPrfUnlockSuccess(userKey: UserKey): Promise<void> {
await this.setUserKeyAndContinue(userKey);
}
togglePassword() {
this.showPassword = !this.showPassword;
}
private validatePin(): boolean {
if (this.formGroup?.invalid) {
this.toastService.showToast({

View File

@@ -54,6 +54,11 @@
</button>
}
<bit-unlock-via-prf
[formButton]="true"
(unlockSuccess)="onPrfUnlockSuccess($event)"
></bit-unlock-via-prf>
<button type="button" bitButton bitFormButton block (click)="logOut.emit()">
{{ "logOut" | i18n }}
</button>

View File

@@ -18,6 +18,7 @@ import { UserKey } from "@bitwarden/common/types/key";
import {
AsyncActionsModule,
ButtonModule,
DialogService,
FormFieldModule,
IconButtonModule,
ToastService,
@@ -27,6 +28,7 @@ import { CommandDefinition, MessageListener } from "@bitwarden/messaging";
import { UserId } from "@bitwarden/user-core";
import { UnlockOption, UnlockOptions } from "../../services/lock-component.service";
import { WebAuthnPrfUnlockService } from "../../services/webauthn-prf-unlock.service";
import { MasterPasswordLockComponent } from "./master-password-lock.component";
@@ -41,6 +43,8 @@ describe("MasterPasswordLockComponent", () => {
const logService = mock<LogService>();
const platformUtilsService = mock<PlatformUtilsService>();
const messageListener = mock<MessageListener>();
const webAuthnPrfUnlockService = mock<WebAuthnPrfUnlockService>();
const dialogService = mock<DialogService>();
const mockMasterPassword = "testExample";
const activeAccount: Account = {
@@ -64,6 +68,7 @@ describe("MasterPasswordLockComponent", () => {
enabled: false,
biometricsStatus: BiometricsStatus.NotEnabledLocally,
},
prf: { enabled: false },
};
accountService.activeAccount$ = of(account);
@@ -110,6 +115,8 @@ describe("MasterPasswordLockComponent", () => {
{ provide: LogService, useValue: logService },
{ provide: PlatformUtilsService, useValue: platformUtilsService },
{ provide: MessageListener, useValue: messageListener },
{ provide: WebAuthnPrfUnlockService, useValue: webAuthnPrfUnlockService },
{ provide: DialogService, useValue: dialogService },
],
}).compileComponents();

View File

@@ -36,6 +36,7 @@ import {
UnlockOptions,
UnlockOptionValue,
} from "../../services/lock-component.service";
import { UnlockViaPrfComponent } from "../unlock-via-prf.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@@ -49,6 +50,7 @@ import {
FormFieldModule,
AsyncActionsModule,
IconButtonModule,
UnlockViaPrfComponent,
],
})
export class MasterPasswordLockComponent implements OnInit, OnDestroy {
@@ -76,6 +78,7 @@ export class MasterPasswordLockComponent implements OnInit, OnDestroy {
});
successfulUnlock = output<{ userKey: UserKey; masterPassword: string }>();
prfUnlockSuccess = output<UserKey>();
logOut = output<void>();
protected showPassword = false;
@@ -143,4 +146,8 @@ export class MasterPasswordLockComponent implements OnInit, OnDestroy {
});
}
}
onPrfUnlockSuccess(userKey: UserKey): void {
this.prfUnlockSuccess.emit(userKey);
}
}

View File

@@ -0,0 +1,114 @@
import { CommonModule } from "@angular/common";
import { Component, OnInit, input, output } from "@angular/core";
import { firstValueFrom } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
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 { UserKey } from "@bitwarden/common/types/key";
import { AsyncActionsModule, ButtonModule, DialogService } from "@bitwarden/components";
import { WebAuthnPrfUnlockService } from "../services/webauthn-prf-unlock.service";
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "bit-unlock-via-prf",
standalone: true,
imports: [CommonModule, JslibModule, ButtonModule, AsyncActionsModule],
template: `
@if (isAvailable) {
@if (formButton()) {
<button
type="button"
bitButton
bitFormButton
buttonType="secondary"
block
(click)="unlockViaPrf()"
[disabled]="unlocking"
[loading]="unlocking"
>
<i class="bwi bwi-passkey tw-mr-1" aria-hidden="true"></i>
{{ "unlockWithPasskey" | i18n }}
</button>
}
@if (!formButton()) {
<button
type="button"
bitButton
buttonType="secondary"
block
(click)="unlockViaPrf()"
[disabled]="unlocking"
[loading]="unlocking"
>
<i class="bwi bwi-passkey tw-mr-1" aria-hidden="true"></i>
{{ "unlockWithPasskey" | i18n }}
</button>
}
}
`,
})
export class UnlockViaPrfComponent implements OnInit {
readonly formButton = input<boolean>(false);
readonly unlockSuccess = output<UserKey>();
unlocking = false;
isAvailable = false;
private userId: UserId | null = null;
constructor(
private accountService: AccountService,
private webAuthnPrfUnlockService: WebAuthnPrfUnlockService,
private dialogService: DialogService,
private i18nService: I18nService,
private logService: LogService,
) {}
async ngOnInit(): Promise<void> {
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
if (activeAccount?.id) {
this.userId = activeAccount.id;
this.isAvailable = await this.webAuthnPrfUnlockService.isPrfUnlockAvailable(this.userId);
}
}
async unlockViaPrf(): Promise<void> {
if (!this.userId || !this.isAvailable) {
return;
}
this.unlocking = true;
try {
const userKey = await this.webAuthnPrfUnlockService.unlockVaultWithPrf(this.userId);
this.unlockSuccess.emit(userKey);
} catch (error) {
this.logService.error("[UnlockViaPrfComponent] Failed to unlock via PRF:", error);
let errorMessage = this.i18nService.t("unexpectedError");
// Handle specific PRF error cases
if (error instanceof Error) {
if (error.message.includes("No PRF credentials")) {
errorMessage = this.i18nService.t("noPrfCredentialsAvailable");
} else if (error.message.includes("canceled")) {
// User canceled the operation, don't show error
this.unlocking = false;
return;
}
}
await this.dialogService.openSimpleDialog({
title: { key: "error" },
content: errorMessage,
acceptButtonText: { key: "ok" },
type: "danger",
});
} finally {
this.unlocking = false;
}
}
}

View File

@@ -0,0 +1,288 @@
import { firstValueFrom } from "rxjs";
import {
UserDecryptionOptions,
UserDecryptionOptionsServiceAbstraction,
WebAuthnPrfUserDecryptionOption,
} from "@bitwarden/auth/common";
import { WebAuthnLoginPrfKeyServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-prf-key.service.abstraction";
import { ClientType } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Fido2Utils } from "@bitwarden/common/platform/services/fido2/fido2-utils";
import { UserId } from "@bitwarden/common/types/guid";
import { PrfKey, UserKey } from "@bitwarden/common/types/key";
import { KeyService } from "@bitwarden/key-management";
import { WebAuthnPrfUnlockService } from "./webauthn-prf-unlock.service";
export class DefaultWebAuthnPrfUnlockService implements WebAuthnPrfUnlockService {
private navigatorCredentials: CredentialsContainer;
constructor(
private webAuthnLoginPrfKeyService: WebAuthnLoginPrfKeyServiceAbstraction,
private keyService: KeyService,
private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction,
private encryptService: EncryptService,
private environmentService: EnvironmentService,
private platformUtilsService: PlatformUtilsService,
private window: Window,
private logService: LogService,
private configService: ConfigService,
) {
this.navigatorCredentials = this.window.navigator.credentials;
}
async isPrfUnlockAvailable(userId: UserId): Promise<boolean> {
try {
// Check if feature flag is enabled
const passkeyUnlockEnabled = await this.configService.getFeatureFlag(
FeatureFlag.PasskeyUnlock,
);
if (!passkeyUnlockEnabled) {
return false;
}
// Check if browser supports WebAuthn
if (!this.navigatorCredentials || !this.navigatorCredentials.get) {
return false;
}
// If we're in the browser extension, check if we're in a Chromium browser
if (
this.platformUtilsService.getClientType() === ClientType.Browser &&
!this.platformUtilsService.isChromium()
) {
return false;
}
// Check if user has any WebAuthn PRF credentials registered
const credentials = await this.getPrfUnlockCredentials(userId);
if (credentials.length === 0) {
return false;
}
return true;
} catch (error) {
this.logService.error("Error checking PRF unlock availability:", error);
return false;
}
}
private async getPrfUnlockCredentials(
userId: UserId,
): Promise<{ credentialId: string; transports: string[] }[]> {
try {
const userDecryptionOptions = await this.getUserDecryptionOptions(userId);
if (!userDecryptionOptions?.webAuthnPrfOptions) {
return [];
}
return userDecryptionOptions.webAuthnPrfOptions.map((option) => ({
credentialId: option.credentialId,
transports: option.transports,
}));
} catch (error) {
this.logService.error("Error getting PRF unlock credentials:", error);
return [];
}
}
/**
* Unlocks the vault using WebAuthn PRF.
*
* @param userId The user ID to unlock vault for
* @returns Promise<UserKey> the decrypted user key
* @throws Error if unlock fails for any reason
*/
async unlockVaultWithPrf(userId: UserId): Promise<UserKey> {
// Get offline PRF credentials from user decryption options
const credentials = await this.getPrfUnlockCredentials(userId);
if (credentials.length === 0) {
throw new Error("No PRF credentials available for unlock");
}
const response = await this.performWebAuthnGetWithPrf(credentials, userId);
const prfKey = await this.createPrfKeyFromResponse(response);
const prfOption = await this.getPrfOptionForCredential(response.id, userId);
// PRF unlock follows the same key derivation process as PRF login:
// PRF key → decrypt private key → use private key to decrypt user key
// Step 1: Decrypt PRF encrypted private key using the PRF key
const privateKey = await this.encryptService.unwrapDecapsulationKey(
new EncString(prfOption.encryptedPrivateKey),
prfKey,
);
// Step 2: Use private key to decrypt user key
const userKey = await this.encryptService.decapsulateKeyUnsigned(
new EncString(prfOption.encryptedUserKey),
privateKey,
);
if (!userKey) {
throw new Error("Failed to decrypt user key from private key");
}
return userKey as UserKey;
}
/**
* Performs WebAuthn get operation with PRF extension.
*
* @param credentials Available PRF credentials for the user
* @returns PublicKeyCredential response from the authenticator
* @throws Error if WebAuthn operation fails or returns invalid response
*/
private async performWebAuthnGetWithPrf(
credentials: { credentialId: string; transports: string[] }[],
userId: UserId,
): Promise<PublicKeyCredential> {
const rpId = await this.getRpIdForUser(userId);
const prfSalt = await this.getUnlockWithPrfSalt();
const options: CredentialRequestOptions = {
publicKey: {
challenge: new Uint8Array(32),
allowCredentials: credentials.map(({ credentialId, transports }) => {
// The credential ID is already base64url encoded from login storage
// We need to decode it to ArrayBuffer for WebAuthn
const decodedId = Fido2Utils.stringToBuffer(credentialId);
return {
type: "public-key",
id: decodedId,
transports: (transports || []) as AuthenticatorTransport[],
};
}),
rpId,
userVerification: "preferred", // Allow platform authenticators to work properly
extensions: {
prf: { eval: { first: prfSalt } },
} as any,
},
};
const response = await this.navigatorCredentials.get(options);
if (!response) {
throw new Error("WebAuthn get() returned null/undefined");
}
if (!(response instanceof PublicKeyCredential)) {
throw new Error("Failed to get PRF credential for unlock");
}
return response;
}
/**
* Extracts PRF result from WebAuthn response and creates a PrfKey.
*
* @param response PublicKeyCredential response from authenticator
* @returns PrfKey derived from the PRF extension output
* @throws Error if no PRF result is present in the response
*/
private async createPrfKeyFromResponse(response: PublicKeyCredential): Promise<PrfKey> {
// Extract PRF result
// TODO: Remove `any` when typescript typings add support for PRF
const extensionResults = response.getClientExtensionResults() as any;
const prfResult = extensionResults.prf?.results?.first;
if (!prfResult) {
throw new Error("No PRF result received from authenticator");
}
try {
return await this.webAuthnLoginPrfKeyService.createSymmetricKeyFromPrf(prfResult);
} catch (error) {
this.logService.error("Failed to create unlock key from PRF:", error);
throw error;
}
}
/**
* Gets the WebAuthn PRF option that matches the credential used in the response.
*
* @param credentialId Credential ID to match
* @param userId User ID to get decryption options for
* @returns Matching WebAuthnPrfUserDecryptionOption with encrypted keys
* @throws Error if no PRF options exist or no matching option is found
*/
private async getPrfOptionForCredential(
credentialId: string,
userId: UserId,
): Promise<WebAuthnPrfUserDecryptionOption> {
const userDecryptionOptions = await this.getUserDecryptionOptions(userId);
if (
!userDecryptionOptions?.webAuthnPrfOptions ||
userDecryptionOptions.webAuthnPrfOptions.length === 0
) {
throw new Error("No WebAuthn PRF option found for user - cannot perform PRF unlock");
}
const prfOption = userDecryptionOptions.webAuthnPrfOptions.find(
(option) => option.credentialId === credentialId,
);
if (!prfOption) {
throw new Error("No matching WebAuthn PRF option found for this credential");
}
return prfOption;
}
private async getUnlockWithPrfSalt(): Promise<ArrayBuffer> {
try {
// Use the same salt as login to ensure PRF keys match
return await this.webAuthnLoginPrfKeyService.getLoginWithPrfSalt();
} catch (error) {
this.logService.error("Error getting unlock PRF salt:", error);
throw error;
}
}
/**
* Helper method to get user decryption options for a user
*/
private async getUserDecryptionOptions(userId: UserId): Promise<UserDecryptionOptions | null> {
try {
return (await firstValueFrom(
this.userDecryptionOptionsService.userDecryptionOptionsById$(userId),
)) as UserDecryptionOptions;
} catch (error) {
this.logService.error("Error getting user decryption options:", error);
return null;
}
}
/**
* Helper method to get the appropriate rpId for WebAuthn PRF operations
* Returns the hostname from the user's environment configuration
*/
private async getRpIdForUser(userId: UserId): Promise<string | undefined> {
try {
const environment = await firstValueFrom(this.environmentService.getEnvironment$(userId));
const hostname = environment.getHostname();
// The navigator.credentials.get call will fail if rpId is set but is null/empty. Undefined uses the current host.
if (!hostname) {
return undefined;
}
// Extract hostname using URL parsing to handle IPv6 and ports correctly
// This removes ports etc.
const url = new URL(`https://${hostname}`);
const rpId = url.hostname;
return rpId;
} catch (error) {
this.logService.error("Error getting rpId", error);
return undefined;
}
}
}

View File

@@ -10,6 +10,7 @@ export const UnlockOption = Object.freeze({
MasterPassword: "masterPassword",
Pin: "pin",
Biometrics: "biometrics",
Prf: "prf",
}) satisfies { [Prop in keyof UnlockOptions as Capitalize<Prop>]: Prop };
export type UnlockOptions = {
@@ -23,6 +24,9 @@ export type UnlockOptions = {
enabled: boolean;
biometricsStatus: BiometricsStatus;
};
prf: {
enabled: boolean;
};
};
/**

View File

@@ -0,0 +1,27 @@
import { UserKey } from "@bitwarden/common/types/key";
import { UserId } from "@bitwarden/user-core";
/**
* Service for unlocking vault using WebAuthn PRF.
* Provides offline vault unlock capabilities by deriving unlock keys from PRF outputs.
*/
export abstract class WebAuthnPrfUnlockService {
/**
* Check if PRF unlock is available for the current user
* @param userId The user ID to check PRF unlock availability for
* @returns Promise<boolean> true if PRF unlock is available
*/
abstract isPrfUnlockAvailable(userId: UserId): Promise<boolean>;
/**
* Attempt to unlock the vault using WebAuthn PRF
* @param userId The user ID to unlock vault for
* @returns Promise<UserKey> the decrypted user key
* @throws Error if no PRF credentials are available
* @throws Error if the authenticator returns no PRF result
* @throws Error if the user cancels the WebAuthn operation
* @throws Error if decryption of the user key fails
* @throws Error if no matching PRF option is found for the credential
*/
abstract unlockVaultWithPrf(userId: UserId): Promise<UserKey>;
}

View File

@@ -1,6 +1,7 @@
import { mock } from "jest-mock-extended";
import { BehaviorSubject, bufferCount, firstValueFrom, lastValueFrom, of, take } from "rxjs";
import { ClientType } from "@bitwarden/client-type";
import { EncryptedOrganizationKeyData } from "@bitwarden/common/admin-console/models/data/encrypted-organization-key.data";
import { KeyGenerationService } from "@bitwarden/common/key-management/crypto";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
@@ -259,7 +260,18 @@ describe("keyService", () => {
});
});
it("clears the Auto key if vault timeout is set to anything other than null", async () => {
it("sets an Auto key if vault timeout is set to 10 minutes and is Cli", async () => {
await stateProvider.setUserState(VAULT_TIMEOUT, 10, mockUserId);
platformUtilService.getClientType.mockReturnValue(ClientType.Cli);
await keyService.setUserKey(mockUserKey, mockUserId);
expect(stateService.setUserKeyAutoUnlock).toHaveBeenCalledWith(mockUserKey.keyB64, {
userId: mockUserId,
});
});
it("clears the Auto key if vault timeout is set to 10 minutes", async () => {
await stateProvider.setUserState(VAULT_TIMEOUT, 10, mockUserId);
await keyService.setUserKey(mockUserKey, mockUserId);

View File

@@ -14,6 +14,7 @@ import {
switchMap,
} from "rxjs";
import { ClientType } from "@bitwarden/client-type";
import { EncryptedOrganizationKeyData } from "@bitwarden/common/admin-console/models/data/encrypted-organization-key.data";
import { BaseEncryptedOrganizationKey } from "@bitwarden/common/admin-console/models/domain/encrypted-organization-key";
import { ProfileOrganizationResponse } from "@bitwarden/common/admin-console/models/response/profile-organization.response";
@@ -671,9 +672,13 @@ export class DefaultKeyService implements KeyServiceAbstraction {
}
protected async shouldStoreKey(keySuffix: KeySuffixOptions, userId: UserId) {
let shouldStoreKey = false;
switch (keySuffix) {
case KeySuffixOptions.Auto: {
// Cli has fixed Never vault timeout, and it should not be affected by a policy.
if (this.platformUtilService.getClientType() == ClientType.Cli) {
return true;
}
// TODO: Sharing the UserKeyDefinition is temporary to get around a circ dep issue between
// the VaultTimeoutSettingsSvc and this service.
// This should be fixed as part of the PM-7082 - Auto Key Service work.
@@ -683,11 +688,14 @@ export class DefaultKeyService implements KeyServiceAbstraction {
.pipe(filter((timeout) => timeout != null)),
);
shouldStoreKey = vaultTimeout == VaultTimeoutStringType.Never;
break;
this.logService.debug(
`[KeyService] Should store auto key for vault timeout ${vaultTimeout}`,
);
return vaultTimeout == VaultTimeoutStringType.Never;
}
}
return shouldStoreKey;
return false;
}
protected async getKeyFromStorage(

View File

@@ -1,4 +1,4 @@
import { Component } from "@angular/core";
import { Component, ChangeDetectionStrategy } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
@@ -7,11 +7,10 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { VaultCarouselSlideComponent } from "./carousel-slide/carousel-slide.component";
import { VaultCarouselComponent } from "./carousel.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-test-carousel-slide",
imports: [VaultCarouselComponent, VaultCarouselSlideComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<vault-carousel label="Storybook Demo">
<vault-carousel-slide label="First Slide">
@@ -93,8 +92,7 @@ describe("VaultCarouselComponent", () => {
const backButton = fixture.debugElement.queryAll(By.css("button"))[0];
middleSlideButton.nativeElement.click();
await new Promise((r) => setTimeout(r, 100)); // Give time for the DOM to update.
fixture.detectChanges();
jest.spyOn(component.slideChange, "emit");
backButton.nativeElement.click();

View File

@@ -22,7 +22,6 @@ import { take } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { ButtonModule, IconButtonModule } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import { VaultCarouselButtonComponent } from "./carousel-button/carousel-button.component";
import { VaultCarouselContentComponent } from "./carousel-content/carousel-content.component";
@@ -41,7 +40,6 @@ import { VaultCarouselSlideComponent } from "./carousel-slide/carousel-slide.com
ButtonModule,
VaultCarouselContentComponent,
VaultCarouselButtonComponent,
I18nPipe,
],
})
export class VaultCarouselComponent implements AfterViewInit {

View File

@@ -16,6 +16,7 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { OrganizationId, CollectionId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { DialogRef, DialogService, ToastService } from "@bitwarden/components";
import { LogService } from "@bitwarden/logging";
@@ -43,6 +44,7 @@ describe("DefaultVaultItemsTransferService", () => {
let mockEventCollectionService: MockProxy<EventCollectionService>;
let mockConfigService: MockProxy<ConfigService>;
let mockOrganizationUserApiService: MockProxy<OrganizationUserApiService>;
let mockSyncService: MockProxy<SyncService>;
const userId = "user-id" as UserId;
const organizationId = "org-id" as OrganizationId;
@@ -79,6 +81,7 @@ describe("DefaultVaultItemsTransferService", () => {
mockEventCollectionService = mock<EventCollectionService>();
mockConfigService = mock<ConfigService>();
mockOrganizationUserApiService = mock<OrganizationUserApiService>();
mockSyncService = mock<SyncService>();
mockI18nService.t.mockImplementation((key) => key);
transferInProgressValues = [];
@@ -95,6 +98,7 @@ describe("DefaultVaultItemsTransferService", () => {
mockEventCollectionService,
mockConfigService,
mockOrganizationUserApiService,
mockSyncService,
);
});
@@ -557,6 +561,8 @@ describe("DefaultVaultItemsTransferService", () => {
mockOrganizationService.organizations$.mockReturnValue(of(options.organizations ?? []));
mockCipherService.cipherViews$.mockReturnValue(of(options.ciphers ?? []));
mockCollectionService.defaultUserCollection$.mockReturnValue(of(options.defaultCollection));
mockSyncService.fullSync.mockResolvedValue(true);
mockOrganizationUserApiService.revokeSelf.mockResolvedValue(undefined);
}
it("does nothing when feature flag is disabled", async () => {
@@ -635,11 +641,11 @@ describe("DefaultVaultItemsTransferService", () => {
mockDialogService.open
.mockReturnValueOnce(createMockDialogRef(TransferItemsDialogResult.Declined))
.mockReturnValueOnce(createMockDialogRef(LeaveConfirmationDialogResult.Confirmed));
mockOrganizationUserApiService.revokeSelf.mockResolvedValue(undefined);
await service.enforceOrganizationDataOwnership(userId);
expect(mockOrganizationUserApiService.revokeSelf).toHaveBeenCalledWith(organizationId);
expect(mockSyncService.fullSync).toHaveBeenCalledWith(true);
expect(mockToastService.showToast).toHaveBeenCalledWith({
variant: "success",
message: "leftOrganization",

View File

@@ -23,6 +23,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { getById } from "@bitwarden/common/platform/misc";
import { OrganizationId, CollectionId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities";
import { DialogService, ToastService } from "@bitwarden/components";
@@ -54,6 +55,7 @@ export class DefaultVaultItemsTransferService implements VaultItemsTransferServi
private eventCollectionService: EventCollectionService,
private configService: ConfigService,
private organizationUserApiService: OrganizationUserApiService,
private syncService: SyncService,
) {}
private _transferInProgressSubject = new BehaviorSubject(false);
@@ -164,7 +166,6 @@ export class DefaultVaultItemsTransferService implements VaultItemsTransferServi
if (!userAcceptedTransfer) {
await this.organizationUserApiService.revokeSelf(migrationInfo.enforcingOrganization.id);
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("leftOrganization"),
@@ -176,6 +177,8 @@ export class DefaultVaultItemsTransferService implements VaultItemsTransferServi
undefined,
migrationInfo.enforcingOrganization.id,
);
// Sync to reflect organization removal
await this.syncService.fullSync(true);
return;
}