1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-13 23:13:36 +00:00

Merge branch 'main' into ps/extension-refresh

This commit is contained in:
Victoria League
2024-08-14 10:53:36 -04:00
committed by GitHub
471 changed files with 18849 additions and 3310 deletions

View File

@@ -24,7 +24,6 @@ export class CollectionsComponent implements OnInit {
collectionIds: string[];
collections: CollectionView[] = [];
organization: Organization;
flexibleCollectionsV1Enabled: boolean;
restrictProviderAccess: boolean;
protected cipherDomain: Cipher;
@@ -40,9 +39,6 @@ export class CollectionsComponent implements OnInit {
) {}
async ngOnInit() {
this.flexibleCollectionsV1Enabled = await this.configService.getFeatureFlag(
FeatureFlag.FlexibleCollectionsV1,
);
this.restrictProviderAccess = await this.configService.getFeatureFlag(
FeatureFlag.RestrictProviderAccess,
);
@@ -72,12 +68,7 @@ export class CollectionsComponent implements OnInit {
async submit(): Promise<boolean> {
const selectedCollectionIds = this.collections
.filter((c) => {
if (
this.organization.canEditAllCiphers(
this.flexibleCollectionsV1Enabled,
this.restrictProviderAccess,
)
) {
if (this.organization.canEditAllCiphers(this.restrictProviderAccess)) {
return !!(c as any).checked;
} else {
return !!(c as any).checked && c.readOnly == null;

View File

@@ -16,14 +16,12 @@ import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractio
import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { VerificationType } from "@bitwarden/common/auth/enums/verification-type";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import {
MasterPasswordVerification,
MasterPasswordVerificationResponse,
} from "@bitwarden/common/auth/types/verification";
import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -136,10 +134,13 @@ export class LockComponent implements OnInit, OnDestroy {
}
await this.biometricStateService.setUserPromptCancelled();
const userKey = await this.cryptoService.getUserKeyFromStorage(KeySuffixOptions.Biometric);
const userKey = await this.cryptoService.getUserKeyFromStorage(
KeySuffixOptions.Biometric,
this.activeUserId,
);
if (userKey) {
await this.setUserKeyAndContinue(userKey, false);
await this.setUserKeyAndContinue(userKey, this.activeUserId, false);
}
return !!userKey;
@@ -176,7 +177,7 @@ export class LockComponent implements OnInit, OnDestroy {
const userKey = await this.pinService.decryptUserKeyWithPin(this.pin, userId);
if (userKey) {
await this.setUserKeyAndContinue(userKey);
await this.setUserKeyAndContinue(userKey, userId);
return; // successfully unlocked
}
@@ -259,11 +260,15 @@ export class LockComponent implements OnInit, OnDestroy {
const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(
response.masterKey,
);
await this.setUserKeyAndContinue(userKey, true);
await this.setUserKeyAndContinue(userKey, userId, true);
}
private async setUserKeyAndContinue(key: UserKey, evaluatePasswordAfterUnlock = false) {
await this.cryptoService.setUserKey(key);
private async setUserKeyAndContinue(
key: UserKey,
userId: UserId,
evaluatePasswordAfterUnlock = false,
) {
await this.cryptoService.setUserKey(key, userId);
// Now that we have a decrypted user key in memory, we can check if we
// need to establish trust on the current device
@@ -316,42 +321,9 @@ export class LockComponent implements OnInit, OnDestroy {
}
private async load(userId: UserId) {
// TODO: Investigate PM-3515
// The loading of the lock component works as follows:
// 1. If the user is unlocked, we're here in error so we navigate to the home page
// 2. First, is locking a valid timeout action? If not, we will log the user out.
// 3. If locking IS a valid timeout action, we proceed to show the user the lock screen.
// The user will be able to unlock as follows:
// - If they have a PIN set, they will be presented with the PIN input
// - If they have a master password and no PIN, they will be presented with the master password input
// - If they have biometrics enabled, they will be presented with the biometric prompt
const isUnlocked = await firstValueFrom(
this.authService
.authStatusFor$(userId)
.pipe(map((status) => status === AuthenticationStatus.Unlocked)),
);
if (isUnlocked) {
// navigate to home
await this.router.navigate(["/"]);
return;
}
const availableVaultTimeoutActions = await firstValueFrom(
this.vaultTimeoutSettingsService.availableVaultTimeoutActions$(userId),
);
const supportsLock = availableVaultTimeoutActions.includes(VaultTimeoutAction.Lock);
if (!supportsLock) {
return await this.vaultTimeoutService.logOut(userId);
}
this.pinLockType = await this.pinService.getPinLockType(userId);
const ephemeralPinSet = await this.pinService.getPinKeyEncryptedUserKeyEphemeral(userId);
this.pinEnabled =
(this.pinLockType === "EPHEMERAL" && !!ephemeralPinSet) || this.pinLockType === "PERSISTENT";
this.pinEnabled = await this.pinService.isPinDecryptionAvailable(userId);
this.masterPasswordEnabled = await this.userVerificationService.hasMasterPassword();

View File

@@ -386,6 +386,7 @@ export class LoginViaAuthRequestComponent
await this.authRequestService.setKeysAfterDecryptingSharedMasterKeyAndHash(
adminAuthReqResponse,
privateKey,
userId,
);
} else {
// Flow 3: masterPasswordHash is null
@@ -393,6 +394,7 @@ export class LoginViaAuthRequestComponent
await this.authRequestService.setUserKeyAfterDecryptingSharedUserKey(
adminAuthReqResponse,
privateKey,
userId,
);
}

View File

@@ -11,7 +11,6 @@
bitInput
appAutofocus
appInputVerbatim
autocomplete="new-password"
[(ngModel)]="tokenValue"
(input)="token.emit(tokenValue)"
/>

View File

@@ -0,0 +1,235 @@
import { TestBed } from "@angular/core/testing";
import { Router } from "@angular/router";
import { RouterTestingModule } from "@angular/router/testing";
import { MockProxy, mock } from "jest-mock-extended";
import { BehaviorSubject, of } from "rxjs";
import { EmptyComponent } from "@bitwarden/angular/platform/guard/feature-flag.guard.spec";
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
import { AccountInfo, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { ClientType } from "@bitwarden/common/enums";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { UserId } from "@bitwarden/common/types/guid";
import { lockGuard } from "./lock.guard";
interface SetupParams {
authStatus: AuthenticationStatus;
canLock?: boolean;
isLegacyUser?: boolean;
clientType?: ClientType;
everHadUserKey?: boolean;
supportsDeviceTrust?: boolean;
hasMasterPassword?: boolean;
}
describe("lockGuard", () => {
const setup = (setupParams: SetupParams) => {
const authService: MockProxy<AuthService> = mock<AuthService>();
authService.authStatusFor$.mockReturnValue(of(setupParams.authStatus));
const vaultTimeoutSettingsService: MockProxy<VaultTimeoutSettingsService> =
mock<VaultTimeoutSettingsService>();
vaultTimeoutSettingsService.canLock.mockResolvedValue(setupParams.canLock);
const cryptoService: MockProxy<CryptoService> = mock<CryptoService>();
cryptoService.isLegacyUser.mockResolvedValue(setupParams.isLegacyUser);
cryptoService.everHadUserKey$ = of(setupParams.everHadUserKey);
const platformUtilService: MockProxy<PlatformUtilsService> = mock<PlatformUtilsService>();
platformUtilService.getClientType.mockReturnValue(setupParams.clientType);
const messagingService: MockProxy<MessagingService> = mock<MessagingService>();
const deviceTrustService: MockProxy<DeviceTrustServiceAbstraction> =
mock<DeviceTrustServiceAbstraction>();
deviceTrustService.supportsDeviceTrust$ = of(setupParams.supportsDeviceTrust);
const userVerificationService: MockProxy<UserVerificationService> =
mock<UserVerificationService>();
userVerificationService.hasMasterPassword.mockResolvedValue(setupParams.hasMasterPassword);
const accountService: MockProxy<AccountService> = mock<AccountService>();
const activeAccountSubject = new BehaviorSubject<{ id: UserId } & AccountInfo>(null);
accountService.activeAccount$ = activeAccountSubject;
activeAccountSubject.next(
Object.assign(
{
name: "Test User 1",
email: "test@email.com",
emailVerified: true,
} as AccountInfo,
{ id: "test-id" as UserId },
),
);
const testBed = TestBed.configureTestingModule({
imports: [
RouterTestingModule.withRoutes([
{ path: "", component: EmptyComponent },
{ path: "lock", component: EmptyComponent, canActivate: [lockGuard()] },
{ path: "non-lock-route", component: EmptyComponent },
{ path: "migrate-legacy-encryption", component: EmptyComponent },
]),
],
providers: [
{ provide: AuthService, useValue: authService },
{ provide: MessagingService, useValue: messagingService },
{ provide: AccountService, useValue: accountService },
{ provide: VaultTimeoutSettingsService, useValue: vaultTimeoutSettingsService },
{ provide: CryptoService, useValue: cryptoService },
{ provide: PlatformUtilsService, useValue: platformUtilService },
{ provide: DeviceTrustServiceAbstraction, useValue: deviceTrustService },
{ provide: UserVerificationService, useValue: userVerificationService },
],
});
return {
router: testBed.inject(Router),
messagingService,
};
};
it("should be created", () => {
const { router } = setup({
authStatus: AuthenticationStatus.Locked,
});
expect(router).toBeTruthy();
});
it("should redirect to the root route when the user is Unlocked", async () => {
const { router } = setup({
authStatus: AuthenticationStatus.Unlocked,
});
await router.navigate(["lock"]);
expect(router.url).toBe("/");
});
it("should redirect to the root route when the user is LoggedOut", async () => {
const { router } = setup({
authStatus: AuthenticationStatus.LoggedOut,
});
await router.navigate(["lock"]);
expect(router.url).toBe("/");
});
it("should allow navigation to the lock route when the user is Locked and they can lock", async () => {
const { router } = setup({
authStatus: AuthenticationStatus.Locked,
canLock: true,
});
await router.navigate(["lock"]);
expect(router.url).toBe("/lock");
});
it("should allow navigation to the lock route when the user is locked, they can lock, and device trust is not supported", async () => {
const { router } = setup({
authStatus: AuthenticationStatus.Locked,
canLock: true,
supportsDeviceTrust: false,
});
await router.navigate(["lock"]);
expect(router.url).toBe("/lock");
});
it("should not allow navigation to the lock route when canLock is false", async () => {
const { router } = setup({
authStatus: AuthenticationStatus.Locked,
canLock: false,
});
await router.navigate(["lock"]);
expect(router.url).toBe("/");
});
it("should log user out if they are a legacy user on a desktop client", async () => {
const { router, messagingService } = setup({
authStatus: AuthenticationStatus.Locked,
canLock: true,
isLegacyUser: true,
clientType: ClientType.Desktop,
});
await router.navigate(["lock"]);
expect(router.url).toBe("/");
expect(messagingService.send).toHaveBeenCalledWith("logout");
});
it("should log user out if they are a legacy user on a browser extension client", async () => {
const { router, messagingService } = setup({
authStatus: AuthenticationStatus.Locked,
canLock: true,
isLegacyUser: true,
clientType: ClientType.Browser,
});
await router.navigate(["lock"]);
expect(router.url).toBe("/");
expect(messagingService.send).toHaveBeenCalledWith("logout");
});
it("should send the user to migrate-legacy-encryption if they are a legacy user on a web client", async () => {
const { router } = setup({
authStatus: AuthenticationStatus.Locked,
canLock: true,
isLegacyUser: true,
clientType: ClientType.Web,
});
await router.navigate(["lock"]);
expect(router.url).toBe("/migrate-legacy-encryption");
});
it("should allow navigation to the lock route when device trust is supported, the user has a MP, and the user is coming from the login-initiated page", async () => {
const { router } = setup({
authStatus: AuthenticationStatus.Locked,
canLock: true,
isLegacyUser: false,
clientType: ClientType.Web,
everHadUserKey: false,
supportsDeviceTrust: true,
hasMasterPassword: true,
});
await router.navigate(["lock"], { queryParams: { from: "login-initiated" } });
expect(router.url).toBe("/lock?from=login-initiated");
});
it("should allow navigation to the lock route when TDE is disabled, the user doesn't have a MP, and the user has had a user key", async () => {
const { router } = setup({
authStatus: AuthenticationStatus.Locked,
canLock: true,
supportsDeviceTrust: false,
hasMasterPassword: false,
everHadUserKey: true,
});
await router.navigate(["lock"]);
expect(router.url).toBe("/lock");
});
it("should not allow navigation to the lock route when device trust is supported and the user has not ever had a user key", async () => {
const { router } = setup({
authStatus: AuthenticationStatus.Locked,
canLock: true,
isLegacyUser: false,
clientType: ClientType.Web,
everHadUserKey: false,
supportsDeviceTrust: true,
hasMasterPassword: false,
});
await router.navigate(["lock"]);
expect(router.url).toBe("/");
});
});

View File

@@ -7,6 +7,8 @@ import {
} from "@angular/router";
import { firstValueFrom } from "rxjs";
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
@@ -19,7 +21,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
/**
* Only allow access to this route if the vault is locked.
* If TDE is enabled then the user must also have had a user key at some point.
* Otherwise redirect to root.
* Otherwise reject navigation.
*
* TODO: This should return Observable<boolean | UrlTree> once we can remove all the promises
*/
@@ -35,12 +37,22 @@ export function lockGuard(): CanActivateFn {
const messagingService = inject(MessagingService);
const router = inject(Router);
const userVerificationService = inject(UserVerificationService);
const vaultTimeoutSettingsService = inject(VaultTimeoutSettingsService);
const accountService = inject(AccountService);
const authStatus = await authService.getAuthStatus();
const activeUser = await firstValueFrom(accountService.activeAccount$);
const authStatus = await firstValueFrom(authService.authStatusFor$(activeUser.id));
if (authStatus !== AuthenticationStatus.Locked) {
return router.createUrlTree(["/"]);
}
// if user can't lock, they can't access the lock screen
const canLock = await vaultTimeoutSettingsService.canLock(activeUser.id);
if (!canLock) {
return false;
}
// If legacy user on web, redirect to migration page
if (await cryptoService.isLegacyUser()) {
if (platformUtilService.getClientType() === ClientType.Web) {
@@ -65,11 +77,10 @@ export function lockGuard(): CanActivateFn {
return true;
}
// If authN user with TDE directly navigates to lock, kick them upwards so redirect guard can
// properly route them to the login decryption options component.
// If authN user with TDE directly navigates to lock, reject that navigation
const everHadUserKey = await firstValueFrom(cryptoService.everHadUserKey$);
if (tdeEnabled && !everHadUserKey) {
return router.createUrlTree(["/"]);
return false;
}
return true;

View File

@@ -34,7 +34,6 @@ export class GeneratorComponent implements OnInit, OnDestroy {
usernameGeneratingPromise: Promise<string>;
typeOptions: any[];
passTypeOptions: any[];
usernameTypeOptions: any[];
subaddressOptions: any[];
catchallOptions: any[];
@@ -48,6 +47,11 @@ export class GeneratorComponent implements OnInit, OnDestroy {
enforcedPasswordPolicyOptions: PasswordGeneratorPolicyOptions;
usernameWebsite: string = null;
get passTypeOptions() {
return this._passTypeOptions.filter((o) => !o.disabled);
}
private _passTypeOptions: { name: string; value: GeneratorType; disabled: boolean }[];
private destroy$ = new Subject<void>();
private isInitialized$ = new BehaviorSubject(false);
@@ -79,9 +83,9 @@ export class GeneratorComponent implements OnInit, OnDestroy {
{ name: i18nService.t("password"), value: "password" },
{ name: i18nService.t("username"), value: "username" },
];
this.passTypeOptions = [
{ name: i18nService.t("password"), value: "password" },
{ name: i18nService.t("passphrase"), value: "passphrase" },
this._passTypeOptions = [
{ name: i18nService.t("password"), value: "password", disabled: false },
{ name: i18nService.t("passphrase"), value: "passphrase", disabled: false },
];
this.usernameTypeOptions = [
{
@@ -138,6 +142,14 @@ export class GeneratorComponent implements OnInit, OnDestroy {
this.passwordOptions.type =
this.passwordOptions.type === "passphrase" ? "passphrase" : "password";
const overrideType = this.enforcedPasswordPolicyOptions.overridePasswordType ?? "";
const isDisabled = overrideType.length
? (value: string, policyValue: string) => value !== policyValue
: (_value: string, _policyValue: string) => false;
for (const option of this._passTypeOptions) {
option.disabled = isDisabled(option.value, overrideType);
}
if (this.usernameOptions.type == null) {
this.usernameOptions.type = "word";
}

View File

@@ -90,7 +90,6 @@ export class AddEditComponent implements OnInit, OnDestroy {
private personalOwnershipPolicyAppliesToActiveUser: boolean;
private previousCipherId: string;
protected flexibleCollectionsV1Enabled = false;
protected restrictProviderAccess = false;
get fido2CredentialCreationDateValue(): string {
@@ -181,9 +180,6 @@ export class AddEditComponent implements OnInit, OnDestroy {
}
async ngOnInit() {
this.flexibleCollectionsV1Enabled = await this.configService.getFeatureFlag(
FeatureFlag.FlexibleCollectionsV1,
);
this.restrictProviderAccess = await this.configService.getFeatureFlag(
FeatureFlag.RestrictProviderAccess,
);
@@ -674,10 +670,7 @@ export class AddEditComponent implements OnInit, OnDestroy {
protected saveCipher(cipher: Cipher) {
const isNotClone = this.editMode && !this.cloneMode;
let orgAdmin = this.organization?.canEditAllCiphers(
this.flexibleCollectionsV1Enabled,
this.restrictProviderAccess,
);
let orgAdmin = this.organization?.canEditAllCiphers(this.restrictProviderAccess);
// if a cipher is unassigned we want to check if they are an admin or have permission to edit any collection
if (!cipher.collectionIds) {
@@ -690,20 +683,14 @@ export class AddEditComponent implements OnInit, OnDestroy {
}
protected deleteCipher() {
const asAdmin = this.organization?.canEditAllCiphers(
this.flexibleCollectionsV1Enabled,
this.restrictProviderAccess,
);
const asAdmin = this.organization?.canEditAllCiphers(this.restrictProviderAccess);
return this.cipher.isDeleted
? this.cipherService.deleteWithServer(this.cipher.id, asAdmin)
: this.cipherService.softDeleteWithServer(this.cipher.id, asAdmin);
}
protected restoreCipher() {
const asAdmin = this.organization?.canEditAllCiphers(
this.flexibleCollectionsV1Enabled,
this.restrictProviderAccess,
);
const asAdmin = this.organization?.canEditAllCiphers(this.restrictProviderAccess);
return this.cipherService.restoreWithServer(this.cipher.id, asAdmin);
}

View File

@@ -10,7 +10,12 @@ import { AnonLayoutWrapperDataService } from "./anon-layout-wrapper-data.service
export interface AnonLayoutWrapperData {
pageTitle?: string;
pageSubtitle?: string;
pageSubtitle?:
| string
| {
subtitle: string;
translate: boolean;
};
pageIcon?: Icon;
showReadonlyHostname?: boolean;
maxWidth?: "md" | "3xl";
@@ -99,7 +104,15 @@ export class AnonLayoutWrapperComponent implements OnInit, OnDestroy {
}
if (data.pageSubtitle) {
this.pageSubtitle = this.i18nService.t(data.pageSubtitle);
// If you pass just a string, we translate it by default
if (typeof data.pageSubtitle === "string") {
this.pageSubtitle = this.i18nService.t(data.pageSubtitle);
} else {
// if you pass an object, you can specify if you want to translate it or not
this.pageSubtitle = data.pageSubtitle.translate
? this.i18nService.t(data.pageSubtitle.subtitle)
: data.pageSubtitle.subtitle;
}
}
if (data.pageIcon) {
@@ -116,6 +129,7 @@ export class AnonLayoutWrapperComponent implements OnInit, OnDestroy {
this.pageSubtitle = null;
this.pageIcon = null;
this.showReadonlyHostname = null;
this.maxWidth = null;
}
ngOnDestroy() {

View File

@@ -177,7 +177,10 @@ const initialData: AnonLayoutWrapperData = {
const changedData: AnonLayoutWrapperData = {
pageTitle: "enterpriseSingleSignOn",
pageSubtitle: "checkYourEmail",
pageSubtitle: {
subtitle: "user@email.com (non-translated)",
translate: false,
},
pageIcon: RegistrationCheckEmailIcon,
};

View File

@@ -76,6 +76,10 @@ export class AnonLayoutComponent implements OnInit, OnChanges {
const theme = await firstValueFrom(this.themeStateService.selectedTheme$);
await this.updateIcon(theme);
}
if (changes.maxWidth) {
this.maxWidth = changes.maxWidth.currentValue ?? "md";
}
}
private async updateIcon(theme: string) {

View File

@@ -49,19 +49,23 @@ export abstract class AuthRequestServiceAbstraction {
* Sets the `UserKey` from an auth request. Auth request must have a `UserKey`.
* @param authReqResponse The auth request.
* @param authReqPrivateKey The private key corresponding to the public key sent in the auth request.
* @param userId The ID of the user for whose account we will set the key.
*/
abstract setUserKeyAfterDecryptingSharedUserKey: (
authReqResponse: AuthRequestResponse,
authReqPrivateKey: ArrayBuffer,
userId: UserId,
) => Promise<void>;
/**
* Sets the `MasterKey` and `MasterKeyHash` from an auth request. Auth request must have a `MasterKey` and `MasterKeyHash`.
* @param authReqResponse The auth request.
* @param authReqPrivateKey The private key corresponding to the public key sent in the auth request.
* @param userId The ID of the user for whose account we will set the keys.
*/
abstract setKeysAfterDecryptingSharedMasterKeyAndHash: (
authReqResponse: AuthRequestResponse,
authReqPrivateKey: ArrayBuffer,
userId: UserId,
) => Promise<void>;
/**
* Decrypts a `UserKey` from a public key encrypted `UserKey`.

View File

@@ -113,9 +113,17 @@ export abstract class PinServiceAbstraction {
/**
* Declares whether or not the user has a PIN set (either persistent or ephemeral).
* Note: for ephemeral, this does not check if we actual have an ephemeral PIN-encrypted UserKey stored in memory.
* Decryption might not be possible even if this returns true. Use {@link isPinDecryptionAvailable} if decryption is required.
*/
abstract isPinSet: (userId: UserId) => Promise<boolean>;
/**
* Checks if PIN-encrypted keys are stored for the user.
* Used for unlock / user verification scenarios where we will need to decrypt the UserKey with the PIN.
*/
abstract isPinDecryptionAvailable: (userId: UserId) => Promise<boolean>;
/**
* Decrypts the UserKey with the provided PIN.
*

View File

@@ -159,7 +159,7 @@ describe("AuthRequestLoginStrategy", () => {
mockUserId,
);
expect(cryptoService.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(tokenResponse.key);
expect(cryptoService.setUserKey).toHaveBeenCalledWith(userKey);
expect(cryptoService.setUserKey).toHaveBeenCalledWith(userKey, mockUserId);
expect(deviceTrustService.trustDeviceIfRequired).toHaveBeenCalled();
expect(cryptoService.setPrivateKey).toHaveBeenCalledWith(tokenResponse.privateKey, mockUserId);
});
@@ -184,7 +184,7 @@ describe("AuthRequestLoginStrategy", () => {
// setMasterKeyEncryptedUserKey, setUserKey, and setPrivateKey should still be called
expect(cryptoService.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(tokenResponse.key);
expect(cryptoService.setUserKey).toHaveBeenCalledWith(decUserKey);
expect(cryptoService.setUserKey).toHaveBeenCalledWith(decUserKey, mockUserId);
expect(cryptoService.setPrivateKey).toHaveBeenCalledWith(tokenResponse.privateKey, mockUserId);
// trustDeviceIfRequired should be called

View File

@@ -102,7 +102,7 @@ export class AuthRequestLoginStrategy extends LoginStrategy {
await this.cryptoService.setMasterKeyEncryptedUserKey(response.key);
if (authRequestCredentials.decryptedUserKey) {
await this.cryptoService.setUserKey(authRequestCredentials.decryptedUserKey);
await this.cryptoService.setUserKey(authRequestCredentials.decryptedUserKey, userId);
} else {
await this.trySetUserKeyWithMasterKey(userId);
@@ -115,7 +115,7 @@ export class AuthRequestLoginStrategy extends LoginStrategy {
const masterKey = await firstValueFrom(this.masterPasswordService.masterKey$(userId));
if (masterKey) {
const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(masterKey);
await this.cryptoService.setUserKey(userKey);
await this.cryptoService.setUserKey(userKey, userId);
}
}

View File

@@ -252,7 +252,7 @@ describe("SsoLoginStrategy", () => {
expect(deviceTrustService.getDeviceKey).toHaveBeenCalledTimes(1);
expect(deviceTrustService.decryptUserKeyWithDeviceKey).toHaveBeenCalledTimes(1);
expect(cryptoSvcSetUserKeySpy).toHaveBeenCalledTimes(1);
expect(cryptoSvcSetUserKeySpy).toHaveBeenCalledWith(mockUserKey);
expect(cryptoSvcSetUserKeySpy).toHaveBeenCalledWith(mockUserKey, userId);
});
it("does not set the user key when deviceKey is missing", async () => {
@@ -498,7 +498,7 @@ describe("SsoLoginStrategy", () => {
undefined,
undefined,
);
expect(cryptoService.setUserKey).toHaveBeenCalledWith(userKey);
expect(cryptoService.setUserKey).toHaveBeenCalledWith(userKey, userId);
});
});
@@ -554,7 +554,7 @@ describe("SsoLoginStrategy", () => {
undefined,
undefined,
);
expect(cryptoService.setUserKey).toHaveBeenCalledWith(userKey);
expect(cryptoService.setUserKey).toHaveBeenCalledWith(userKey, userId);
});
});
});

View File

@@ -255,6 +255,7 @@ export class SsoLoginStrategy extends LoginStrategy {
await this.authRequestService.setKeysAfterDecryptingSharedMasterKeyAndHash(
adminAuthReqResponse,
adminAuthReqStorable.privateKey,
userId,
);
} else {
// if masterPasswordHash is null, we will always receive authReqResponse.key
@@ -262,6 +263,7 @@ export class SsoLoginStrategy extends LoginStrategy {
await this.authRequestService.setUserKeyAfterDecryptingSharedUserKey(
adminAuthReqResponse,
adminAuthReqStorable.privateKey,
userId,
);
}
@@ -321,7 +323,7 @@ export class SsoLoginStrategy extends LoginStrategy {
);
if (userKey) {
await this.cryptoService.setUserKey(userKey);
await this.cryptoService.setUserKey(userKey, userId);
}
}
@@ -337,7 +339,7 @@ export class SsoLoginStrategy extends LoginStrategy {
}
const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(masterKey);
await this.cryptoService.setUserKey(userKey);
await this.cryptoService.setUserKey(userKey, userId);
}
protected override async setPrivateKey(

View File

@@ -133,14 +133,18 @@ describe("AuthRequestService", () => {
cryptoService.setUserKey.mockResolvedValueOnce(undefined);
// Act
await sut.setUserKeyAfterDecryptingSharedUserKey(mockAuthReqResponse, mockPrivateKey);
await sut.setUserKeyAfterDecryptingSharedUserKey(
mockAuthReqResponse,
mockPrivateKey,
mockUserId,
);
// Assert
expect(sut.decryptPubKeyEncryptedUserKey).toBeCalledWith(
mockAuthReqResponse.key,
mockPrivateKey,
);
expect(cryptoService.setUserKey).toBeCalledWith(mockDecryptedUserKey);
expect(cryptoService.setUserKey).toBeCalledWith(mockDecryptedUserKey, mockUserId);
});
});
@@ -169,7 +173,11 @@ describe("AuthRequestService", () => {
cryptoService.setUserKey.mockResolvedValueOnce(undefined);
// Act
await sut.setKeysAfterDecryptingSharedMasterKeyAndHash(mockAuthReqResponse, mockPrivateKey);
await sut.setKeysAfterDecryptingSharedMasterKeyAndHash(
mockAuthReqResponse,
mockPrivateKey,
mockUserId,
);
// Assert
expect(sut.decryptPubKeyEncryptedMasterKeyAndHash).toBeCalledWith(
@@ -190,7 +198,7 @@ describe("AuthRequestService", () => {
undefined,
undefined,
);
expect(cryptoService.setUserKey).toHaveBeenCalledWith(mockDecryptedUserKey);
expect(cryptoService.setUserKey).toHaveBeenCalledWith(mockDecryptedUserKey, mockUserId);
});
});

View File

@@ -126,17 +126,19 @@ export class AuthRequestService implements AuthRequestServiceAbstraction {
async setUserKeyAfterDecryptingSharedUserKey(
authReqResponse: AuthRequestResponse,
authReqPrivateKey: Uint8Array,
userId: UserId,
) {
const userKey = await this.decryptPubKeyEncryptedUserKey(
authReqResponse.key,
authReqPrivateKey,
);
await this.cryptoService.setUserKey(userKey);
await this.cryptoService.setUserKey(userKey, userId);
}
async setKeysAfterDecryptingSharedMasterKeyAndHash(
authReqResponse: AuthRequestResponse,
authReqPrivateKey: Uint8Array,
userId: UserId,
) {
const { masterKey, masterKeyHash } = await this.decryptPubKeyEncryptedMasterKeyAndHash(
authReqResponse.key,
@@ -148,11 +150,10 @@ export class AuthRequestService implements AuthRequestServiceAbstraction {
const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(masterKey);
// Set masterKey + masterKeyHash in state after decryption (in case decryption fails)
const userId = (await firstValueFrom(this.accountService.activeAccount$)).id;
await this.masterPasswordService.setMasterKey(masterKey, userId);
await this.masterPasswordService.setMasterKeyHash(masterKeyHash, userId);
await this.cryptoService.setUserKey(userKey);
await this.cryptoService.setUserKey(userKey, userId);
}
// Decryption helpers

View File

@@ -292,6 +292,34 @@ export class PinService implements PinServiceAbstraction {
return (await this.getPinLockType(userId)) !== "DISABLED";
}
async isPinDecryptionAvailable(userId: UserId): Promise<boolean> {
this.validateUserId(userId, "Cannot determine if decryption of user key via PIN is available.");
const pinLockType = await this.getPinLockType(userId);
switch (pinLockType) {
case "DISABLED":
return false;
case "PERSISTENT":
// The above getPinLockType call ensures that we have either a PinKeyEncryptedUserKey or OldPinKeyEncryptedMasterKey set.
return true;
case "EPHEMERAL": {
// The above getPinLockType call ensures that we have a UserKeyEncryptedPin set.
// However, we must additively check to ensure that we have a set PinKeyEncryptedUserKeyEphemeral b/c otherwise
// we cannot take a PIN, derive a PIN key, and decrypt the ephemeral UserKey.
const pinKeyEncryptedUserKeyEphemeral =
await this.getPinKeyEncryptedUserKeyEphemeral(userId);
return Boolean(pinKeyEncryptedUserKeyEphemeral);
}
default: {
// Compile-time check for exhaustive switch
const _exhaustiveCheck: never = pinLockType;
throw new Error(`Unexpected pinLockType: ${_exhaustiveCheck}`);
}
}
}
async decryptUserKeyWithPin(pin: string, userId: UserId): Promise<UserKey | null> {
this.validateUserId(userId, "Cannot decrypt user key with PIN.");

View File

@@ -416,6 +416,66 @@ describe("PinService", () => {
});
});
describe("isPinDecryptionAvailable()", () => {
it("should return false if pinLockType is DISABLED", async () => {
// Arrange
sut.getPinLockType = jest.fn().mockResolvedValue("DISABLED");
// Act
const result = await sut.isPinDecryptionAvailable(mockUserId);
// Assert
expect(result).toBe(false);
});
it("should return true if pinLockType is PERSISTENT", async () => {
// Arrange
sut.getPinLockType = jest.fn().mockResolvedValue("PERSISTENT");
// Act
const result = await sut.isPinDecryptionAvailable(mockUserId);
// Assert
expect(result).toBe(true);
});
it("should return true if pinLockType is EPHEMERAL and we have an ephemeral PIN key encrypted user key", async () => {
// Arrange
sut.getPinLockType = jest.fn().mockResolvedValue("EPHEMERAL");
sut.getPinKeyEncryptedUserKeyEphemeral = jest
.fn()
.mockResolvedValue(pinKeyEncryptedUserKeyEphemeral);
// Act
const result = await sut.isPinDecryptionAvailable(mockUserId);
// Assert
expect(result).toBe(true);
});
it("should return false if pinLockType is EPHEMERAL and we do not have an ephemeral PIN key encrypted user key", async () => {
// Arrange
sut.getPinLockType = jest.fn().mockResolvedValue("EPHEMERAL");
sut.getPinKeyEncryptedUserKeyEphemeral = jest.fn().mockResolvedValue(null);
// Act
const result = await sut.isPinDecryptionAvailable(mockUserId);
// Assert
expect(result).toBe(false);
});
it("should throw an error if an unexpected pinLockType is returned", async () => {
// Arrange
sut.getPinLockType = jest.fn().mockResolvedValue("UNKNOWN");
// Act & Assert
await expect(sut.isPinDecryptionAvailable(mockUserId)).rejects.toThrow(
"Unexpected pinLockType: UNKNOWN",
);
});
});
describe("decryptUserKeyWithPin()", () => {
async function setupDecryptUserKeyWithPinMocks(
pinLockType: PinLockType,

View File

@@ -25,6 +25,12 @@ export abstract class VaultTimeoutSettingsService {
*/
availableVaultTimeoutActions$: (userId?: string) => Observable<VaultTimeoutAction[]>;
/**
* Evaluates the user's available vault timeout actions and returns a boolean representing
* if the user can lock or not
*/
canLock: (userId: string) => Promise<boolean>;
/**
* Gets the vault timeout action for the given user id. The returned value is
* calculated based on the current state, if a max vault timeout policy applies to the user,

View File

@@ -11,4 +11,5 @@ export enum PolicyType {
MaximumVaultTimeout = 9, // Sets the maximum allowed vault timeout
DisablePersonalVaultExport = 10, // Disable personal vault export
ActivateAutofill = 11, // Activates autofill with page load on the browser extension
AutomaticAppLogIn = 12, // Enables automatic log in of apps from configured identity provider
}

View File

@@ -168,13 +168,8 @@ export class Organization {
);
}
canEditAnyCollection(flexibleCollectionsV1Enabled: boolean) {
if (!flexibleCollectionsV1Enabled) {
// Pre-Flexible Collections v1 logic
return this.isAdmin || this.permissions.editAnyCollection;
}
// Post Flexible Collections V1, the allowAdminAccessToAllCollectionItems flag can restrict admins
get canEditAnyCollection() {
// The allowAdminAccessToAllCollectionItems flag can restrict admins
// Providers and custom users with canEditAnyCollection are not affected by allowAdminAccessToAllCollectionItems flag
return (
this.isProviderUser ||
@@ -183,7 +178,7 @@ export class Organization {
);
}
canEditUnmanagedCollections() {
get canEditUnmanagedCollections() {
// Any admin or custom user with editAnyCollection permission can edit unmanaged collections
return this.isAdmin || this.permissions.editAnyCollection;
}
@@ -203,15 +198,7 @@ export class Organization {
);
}
canEditAllCiphers(
flexibleCollectionsV1Enabled: boolean,
restrictProviderAccessFlagEnabled: boolean,
) {
// Before Flexible Collections V1, any admin or anyone with editAnyCollection permission could edit all ciphers
if (!flexibleCollectionsV1Enabled) {
return this.isAdmin || this.permissions.editAnyCollection;
}
canEditAllCiphers(restrictProviderAccessFlagEnabled: boolean) {
// Providers can access items until the restrictProviderAccess flag is enabled
// After the flag is enabled and removed, this block will be deleted
// so that they permanently lose access to items
@@ -219,7 +206,7 @@ export class Organization {
return true;
}
// Post Flexible Collections V1, the allowAdminAccessToAllCollectionItems flag can restrict admins
// The allowAdminAccessToAllCollectionItems flag can restrict admins
// Custom users with canEditAnyCollection are not affected by allowAdminAccessToAllCollectionItems flag
return (
(this.type === OrganizationUserType.Custom && this.permissions.editAnyCollection) ||
@@ -229,10 +216,9 @@ export class Organization {
}
/**
* @param flexibleCollectionsV1Enabled - Whether or not the V1 Flexible Collection feature flag is enabled
* @returns True if the user can delete any collection
*/
canDeleteAnyCollection(flexibleCollectionsV1Enabled: boolean) {
get canDeleteAnyCollection() {
// Providers and Users with DeleteAnyCollection permission can always delete collections
if (this.isProviderUser || this.permissions.deleteAnyCollection) {
return true;
@@ -240,7 +226,7 @@ export class Organization {
// If AllowAdminAccessToAllCollectionItems is true, Owners and Admins can delete any collection, regardless of LimitCollectionCreationDeletion setting
// Using explicit type checks because provider users are handled above and this mimics the server's permission checks closely
if (!flexibleCollectionsV1Enabled || this.allowAdminAccessToAllCollectionItems) {
if (this.allowAdminAccessToAllCollectionItems) {
return this.type == OrganizationUserType.Owner || this.type == OrganizationUserType.Admin;
}

View File

@@ -5,7 +5,7 @@ import Domain from "../../../platform/models/domain/domain-base";
*/
export class PasswordGeneratorPolicyOptions extends Domain {
/** The default kind of credential to generate */
defaultType: "password" | "passphrase" | "" = "";
overridePasswordType: "password" | "passphrase" | "" = "";
/** The minimum length of generated passwords.
* When this is less than or equal to zero, it is ignored.
@@ -70,7 +70,7 @@ export class PasswordGeneratorPolicyOptions extends Domain {
*/
inEffect() {
return (
this.defaultType ||
this.overridePasswordType ||
this.minLength > 0 ||
this.numberCount > 0 ||
this.specialCount > 0 ||

View File

@@ -410,6 +410,12 @@ describe("UserVerificationService", () => {
function setPinAvailability(type: PinLockType) {
pinService.getPinLockType.mockResolvedValue(type);
if (type === "EPHEMERAL" || type === "PERSISTENT") {
pinService.isPinDecryptionAvailable.mockResolvedValue(true);
} else if (type === "DISABLED") {
pinService.isPinDecryptionAvailable.mockResolvedValue(false);
}
}
function disableBiometricsAvailability() {

View File

@@ -57,13 +57,17 @@ export class UserVerificationService implements UserVerificationServiceAbstracti
): Promise<UserVerificationOptions> {
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
if (verificationType === "client") {
const [userHasMasterPassword, pinLockType, biometricsLockSet, biometricsUserKeyStored] =
await Promise.all([
this.hasMasterPasswordAndMasterKeyHash(userId),
this.pinService.getPinLockType(userId),
this.vaultTimeoutSettingsService.isBiometricLockSet(userId),
this.cryptoService.hasUserKeyStored(KeySuffixOptions.Biometric, userId),
]);
const [
userHasMasterPassword,
isPinDecryptionAvailable,
biometricsLockSet,
biometricsUserKeyStored,
] = await Promise.all([
this.hasMasterPasswordAndMasterKeyHash(userId),
this.pinService.isPinDecryptionAvailable(userId),
this.vaultTimeoutSettingsService.isBiometricLockSet(userId),
this.cryptoService.hasUserKeyStored(KeySuffixOptions.Biometric, userId),
]);
// note: we do not need to check this.platformUtilsService.supportsBiometric() because
// we can just use the logic below which works for both desktop & the browser extension.
@@ -71,7 +75,7 @@ export class UserVerificationService implements UserVerificationServiceAbstracti
return {
client: {
masterPassword: userHasMasterPassword,
pin: pinLockType !== "DISABLED",
pin: isPinDecryptionAvailable,
biometrics:
biometricsLockSet &&
(biometricsUserKeyStored || !this.platformUtilsService.supportsSecureStorage()),

View File

@@ -56,6 +56,8 @@ export const NOTIFICATION_BAR_LIFESPAN_MS = 150000; // 150 seconds
export const AUTOFILL_OVERLAY_HANDLE_REPOSITION = "autofill-overlay-handle-reposition-event";
export const UPDATE_PASSKEYS_HEADINGS_ON_SCROLL = "update-passkeys-headings-on-scroll";
export const AutofillOverlayVisibility = {
Off: 0,
OnButtonClick: 1,

View File

@@ -146,9 +146,8 @@ export class AutofillSettingsService implements AutofillSettingsServiceAbstracti
this.autofillOnPageLoadPolicyToastHasDisplayedState = this.stateProvider.getActive(
AUTOFILL_ON_PAGE_LOAD_POLICY_TOAST_HAS_DISPLAYED,
);
this.autofillOnPageLoadPolicyToastHasDisplayed$ = this.autofillOnPageLoadState.state$.pipe(
map((x) => x ?? false),
);
this.autofillOnPageLoadPolicyToastHasDisplayed$ =
this.autofillOnPageLoadPolicyToastHasDisplayedState.state$.pipe(map((x) => x ?? false));
this.autoCopyTotpState = this.stateProvider.getActive(AUTO_COPY_TOTP);
this.autoCopyTotp$ = this.autoCopyTotpState.state$.pipe(map((x) => x ?? true));

View File

@@ -6,13 +6,13 @@
export enum FeatureFlag {
BrowserFilelessImport = "browser-fileless-import",
ItemShare = "item-share",
FlexibleCollectionsV1 = "flexible-collections-v-1", // v-1 is intentional
GeneratorToolsModernization = "generator-tools-modernization",
ShowPaymentMethodWarningBanners = "show-payment-method-warning-banners",
EnableConsolidatedBilling = "enable-consolidated-billing",
AC1795_UpdatedSubscriptionStatusSection = "AC-1795_updated-subscription-status-section",
EnableDeleteProvider = "AC-1218-delete-provider",
ExtensionRefresh = "extension-refresh",
PersistPopupView = "persist-popup-view",
RestrictProviderAccess = "restrict-provider-access",
PM4154_BulkEncryptionService = "PM-4154-bulk-encryption-service",
UseTreeWalkerApiForPageDetailsCollection = "use-tree-walker-api-for-page-details-collection",
@@ -25,6 +25,7 @@ export enum FeatureFlag {
ProviderClientVaultPrivacyBanner = "ac-2833-provider-client-vault-privacy-banner",
VaultBulkManagementAction = "vault-bulk-management-action",
AC2828_ProviderPortalMembersPage = "AC-2828_provider-portal-members-page",
IdpAutoSubmitLogin = "idp-auto-submit-login",
DeviceTrustLogging = "pm-8285-device-trust-logging",
AuthenticatorTwoFactorToken = "authenticator-2fa-token",
UnauthenticatedExtensionUIRefresh = "unauth-ui-refresh",
@@ -47,13 +48,13 @@ const FALSE = false as boolean;
export const DefaultFeatureFlagValue = {
[FeatureFlag.BrowserFilelessImport]: FALSE,
[FeatureFlag.ItemShare]: FALSE,
[FeatureFlag.FlexibleCollectionsV1]: FALSE,
[FeatureFlag.GeneratorToolsModernization]: FALSE,
[FeatureFlag.ShowPaymentMethodWarningBanners]: FALSE,
[FeatureFlag.EnableConsolidatedBilling]: FALSE,
[FeatureFlag.AC1795_UpdatedSubscriptionStatusSection]: FALSE,
[FeatureFlag.EnableDeleteProvider]: FALSE,
[FeatureFlag.ExtensionRefresh]: FALSE,
[FeatureFlag.PersistPopupView]: FALSE,
[FeatureFlag.RestrictProviderAccess]: FALSE,
[FeatureFlag.PM4154_BulkEncryptionService]: FALSE,
[FeatureFlag.UseTreeWalkerApiForPageDetailsCollection]: FALSE,
@@ -66,6 +67,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.ProviderClientVaultPrivacyBanner]: FALSE,
[FeatureFlag.VaultBulkManagementAction]: FALSE,
[FeatureFlag.AC2828_ProviderPortalMembersPage]: FALSE,
[FeatureFlag.IdpAutoSubmitLogin]: FALSE,
[FeatureFlag.DeviceTrustLogging]: FALSE,
[FeatureFlag.AuthenticatorTwoFactorToken]: FALSE,
[FeatureFlag.UnauthenticatedExtensionUIRefresh]: FALSE,

View File

@@ -0,0 +1,21 @@
import { Observable, Subject } from "rxjs";
import { Fido2CredentialView } from "../../../vault/models/view/fido2-credential.view";
export interface ActiveRequest {
credentials: Fido2CredentialView[];
subject: Subject<string>;
}
export type RequestCollection = Readonly<{ [tabId: number]: ActiveRequest }>;
export abstract class Fido2ActiveRequestManager {
getActiveRequest$: (tabId: number) => Observable<ActiveRequest | undefined>;
getActiveRequest: (tabId: number) => ActiveRequest | undefined;
newActiveRequest: (
tabId: number,
credentials: Fido2CredentialView[],
abortController: AbortController,
) => Promise<string>;
removeActiveRequest: (tabId: number) => void;
}

View File

@@ -1,3 +1,5 @@
import { Fido2CredentialView } from "../../../vault/models/view/fido2-credential.view";
/**
* This class represents an abstraction of the WebAuthn Authenticator model as described by W3C:
* https://www.w3.org/TR/webauthn-3/#sctn-authenticator-model
@@ -32,6 +34,14 @@ export abstract class Fido2AuthenticatorService {
tab: chrome.tabs.Tab,
abortController?: AbortController,
) => Promise<Fido2AuthenticatorGetAssertionResult>;
/**
* Discover credentials for a given Relying Party
*
* @param rpId The Relying Party's ID
* @returns A promise that resolves with an array of discoverable credentials
*/
silentCredentialDiscovery: (rpId: string) => Promise<Fido2CredentialView[]>;
}
export enum Fido2AlgorithmIdentifier {
@@ -132,6 +142,9 @@ export interface Fido2AuthenticatorGetAssertionParams {
extensions: unknown;
/** Forwarded to user interface */
fallbackSupported: boolean;
// Bypass the UI and assume that the user has already interacted with the authenticator
assumeUserPresence?: boolean;
}
export interface Fido2AuthenticatorGetAssertionResult {

View File

@@ -1,3 +1,7 @@
import { Observable } from "rxjs";
import { Fido2CredentialView } from "../../../vault/models/view/fido2-credential.view";
export const UserRequestedFallbackAbortReason = "UserRequestedFallback";
export type UserVerification = "discouraged" | "preferred" | "required";
@@ -16,6 +20,10 @@ export type UserVerification = "discouraged" | "preferred" | "required";
export abstract class Fido2ClientService {
isFido2FeatureEnabled: (hostname: string, origin: string) => Promise<boolean>;
availableAutofillCredentials$: (tabId: number) => Observable<Fido2CredentialView[]>;
autofillCredential: (tabId: number, credentialId: string) => Promise<void>;
/**
* Allows WebAuthn Relying Party scripts to request the creation of a new public key credential source.
* For more information please see: https://www.w3.org/TR/webauthn-3/#sctn-createCredential
@@ -142,6 +150,7 @@ export interface AssertCredentialParams {
userVerification?: UserVerification;
timeout: number;
sameOriginWithAncestors: boolean;
mediation?: "silent" | "optional" | "required" | "conditional";
fallbackSupported: boolean;
}

View File

@@ -0,0 +1,35 @@
import { compareValues } from "./compare-values";
describe("compareValues", () => {
it("should return true for equal primitive values", () => {
expect(compareValues(1, 1)).toEqual(true);
expect(compareValues("bitwarden", "bitwarden")).toEqual(true);
expect(compareValues(true, true)).toEqual(true);
});
it("should return false for different primitive values", () => {
expect(compareValues(1, 2)).toEqual(false);
expect(compareValues("bitwarden", "bitwarden.com")).toEqual(false);
expect(compareValues(true, false)).toEqual(false);
});
it("should return true when both values are null", () => {
expect(compareValues(null, null)).toEqual(true);
});
it("should compare deeply nested objects correctly", () => {
// Deeply nested objects
const obj1 = { a: 1, b: { c: 2, d: { e: 3, f: [4, 5, 6] } }, g: [7, 8, { h: 9 }] };
const obj2 = { a: 1, b: { c: 2, d: { e: 3, f: [4, 5, 6] } }, g: [7, 8, { h: 9 }] };
expect(compareValues(obj1, obj2)).toEqual(true);
});
it("should return false for deeply nested objects with different values", () => {
// Deeply nested objects
const obj1 = { a: 1, b: { c: 2, d: { e: 3, f: [4, 5, 6] } }, g: [7, 8, { h: 9 }] };
const obj2 = { a: 1, b: { c: 2, d: { e: 3, f: [4, 5, 7] } }, g: [7, 8, { h: 9 }] };
expect(compareValues(obj1, obj2)).toEqual(false);
});
});

View File

@@ -0,0 +1,27 @@
/**
* Performs deep equality check between two values
*
* NOTE: This method uses JSON.stringify to compare objects, which may return false
* for objects with the same properties but in different order. If order-insensitive
* comparison becomes necessary in future, consider updating this method to use a comparison
* that checks for property existence and value equality without regard to order.
*/
export function compareValues<T>(value1: T, value2: T): boolean {
if (value1 == null && value2 == null) {
return true;
}
if (value1 && value2 == null) {
return false;
}
if (value1 == null && value2) {
return false;
}
if (typeof value1 !== "object" || typeof value2 !== "object") {
return value1 === value2;
}
return JSON.stringify(value1) === JSON.stringify(value2);
}

View File

@@ -263,6 +263,12 @@ describe("cryptoService", () => {
await expect(cryptoService.setUserKey(null, mockUserId)).rejects.toThrow("No key provided.");
});
it("throws if userId is null", async () => {
await expect(cryptoService.setUserKey(mockUserKey, null)).rejects.toThrow(
"No userId provided.",
);
});
describe("Pin Key refresh", () => {
const mockPinKeyEncryptedUserKey = new EncString(
"2.AAAw2vTUePO+CCyokcIfVw==|DTBNlJ5yVsV2Bsk3UU3H6Q==|YvFBff5gxWqM+UsFB6BKimKxhC32AtjF3IStpU1Ijwg=",

View File

@@ -89,12 +89,16 @@ export class CryptoService implements CryptoServiceAbstraction {
);
}
async setUserKey(key: UserKey, userId?: UserId): Promise<void> {
async setUserKey(key: UserKey, userId: UserId): Promise<void> {
if (key == null) {
throw new Error("No key provided. Lock the user to clear the key");
}
if (userId == null) {
throw new Error("No userId provided.");
}
// Set userId to ensure we have one for the account status update
[userId, key] = await this.stateProvider.setUserState(USER_KEY, key, userId);
await this.stateProvider.setUserState(USER_KEY, key, userId);
await this.stateProvider.setUserState(USER_EVER_HAD_USER_KEY, true, userId);
await this.storeAdditionalKeys(key, userId);
@@ -701,13 +705,7 @@ export class CryptoService implements CryptoServiceAbstraction {
* @param key The user key
* @param userId The desired user
*/
protected async storeAdditionalKeys(key: UserKey, userId?: UserId) {
userId ??= await firstValueFrom(this.stateProvider.activeUserId$);
if (userId == null) {
throw new Error("Cannot store additional keys, no user Id resolved.");
}
protected async storeAdditionalKeys(key: UserKey, userId: UserId) {
const storeAuto = await this.shouldStoreKey(KeySuffixOptions.Auto, userId);
if (storeAuto) {
await this.stateService.setUserKeyAutoUnlock(key.keyB64, { userId: userId });

View File

@@ -0,0 +1,89 @@
import { mock } from "jest-mock-extended";
import { firstValueFrom, Observable } from "rxjs";
import { flushPromises } from "@bitwarden/browser/src/autofill/spec/testing-utils";
import { Fido2CredentialView } from "../../../vault/models/view/fido2-credential.view";
import { Fido2ActiveRequestManager } from "./fido2-active-request-manager";
jest.mock("rxjs", () => {
const rxjs = jest.requireActual("rxjs");
const { firstValueFrom } = rxjs;
return {
...rxjs,
firstValueFrom: jest.fn(firstValueFrom),
};
});
describe("Fido2ActiveRequestManager", () => {
const credentialId = "123";
const tabId = 1;
let requestManager: Fido2ActiveRequestManager;
beforeEach(() => {
requestManager = new Fido2ActiveRequestManager();
});
afterEach(() => {
jest.clearAllMocks();
});
it("creates a new active request", async () => {
const fido2CredentialView = mock<Fido2CredentialView>({
credentialId,
});
const credentials = [fido2CredentialView];
const abortController = new AbortController();
(firstValueFrom as jest.Mock).mockResolvedValue(credentialId);
const result = await requestManager.newActiveRequest(tabId, credentials, abortController);
await flushPromises();
expect(result).toBe(credentialId);
});
it("gets the observable stream of active requests", async () => {
(firstValueFrom as jest.Mock).mockResolvedValue(credentialId);
await requestManager.newActiveRequest(tabId, [], new AbortController());
const result = requestManager.getActiveRequest$(tabId);
expect(result).toBeInstanceOf(Observable);
result.subscribe((activeRequest) => {
expect(activeRequest).toBeDefined();
});
});
it("returns the active request associated with a given tab id", async () => {
const fido2CredentialView = mock<Fido2CredentialView>({
credentialId,
});
const credentials = [fido2CredentialView];
(firstValueFrom as jest.Mock).mockResolvedValue(credentialId);
await requestManager.newActiveRequest(tabId, credentials, new AbortController());
const result = requestManager.getActiveRequest(tabId);
expect(result).toEqual({
credentials: credentials,
subject: expect.any(Object),
});
});
it("removes the active request associated with a given tab id", async () => {
const fido2CredentialView = mock<Fido2CredentialView>({
credentialId,
});
const credentials = [fido2CredentialView];
(firstValueFrom as jest.Mock).mockResolvedValue(credentialId);
await requestManager.newActiveRequest(tabId, credentials, new AbortController());
requestManager.removeActiveRequest(tabId);
const result = requestManager.getActiveRequest(tabId);
expect(result).toBeUndefined();
});
});

View File

@@ -0,0 +1,109 @@
import {
BehaviorSubject,
distinctUntilChanged,
firstValueFrom,
map,
Observable,
shareReplay,
startWith,
Subject,
} from "rxjs";
import { Fido2CredentialView } from "../../../vault/models/view/fido2-credential.view";
import {
ActiveRequest,
RequestCollection,
Fido2ActiveRequestManager as Fido2ActiveRequestManagerAbstraction,
} from "../../abstractions/fido2/fido2-active-request-manager.abstraction";
export class Fido2ActiveRequestManager implements Fido2ActiveRequestManagerAbstraction {
private activeRequests$: BehaviorSubject<RequestCollection> = new BehaviorSubject({});
/**
* Gets the observable stream of all active requests associated with a given tab id.
*
* @param tabId - The tab id to get the active request for.
*/
getActiveRequest$(tabId: number): Observable<ActiveRequest | undefined> {
return this.activeRequests$.pipe(
map((requests) => requests[tabId]),
distinctUntilChanged(),
shareReplay({ bufferSize: 1, refCount: true }),
startWith(undefined),
);
}
/**
* Gets the active request associated with a given tab id.
*
* @param tabId - The tab id to get the active request for.
*/
getActiveRequest(tabId: number): ActiveRequest | undefined {
return this.activeRequests$.value[tabId];
}
/**
* Creates a new active fido2 request.
*
* @param tabId - The tab id to associate the request with.
* @param credentials - The credentials to use for the request.
* @param abortController - The abort controller to use for the request.
*/
async newActiveRequest(
tabId: number,
credentials: Fido2CredentialView[],
abortController: AbortController,
): Promise<string> {
const newRequest: ActiveRequest = {
credentials,
subject: new Subject(),
};
this.updateRequests((existingRequests) => ({
...existingRequests,
[tabId]: newRequest,
}));
const abortListener = () => this.abortActiveRequest(tabId);
abortController.signal.addEventListener("abort", abortListener);
const credentialId = firstValueFrom(newRequest.subject);
abortController.signal.removeEventListener("abort", abortListener);
return credentialId;
}
/**
* Removes and aborts the active request associated with a given tab id.
*
* @param tabId - The tab id to abort the active request for.
*/
removeActiveRequest(tabId: number) {
this.abortActiveRequest(tabId);
this.updateRequests((existingRequests) => {
const newRequests = { ...existingRequests };
delete newRequests[tabId];
return newRequests;
});
}
/**
* Aborts the active request associated with a given tab id.
*
* @param tabId - The tab id to abort the active request for.
*/
private abortActiveRequest(tabId: number): void {
this.activeRequests$.value[tabId]?.subject.error(
new DOMException("The operation either timed out or was not allowed.", "AbortError"),
);
}
/**
* Updates the active requests.
*
* @param updateFunction - The function to use to update the active requests.
*/
private updateRequests(
updateFunction: (existingRequests: RequestCollection) => RequestCollection,
) {
this.activeRequests$.next(updateFunction(this.activeRequests$.value));
}
}

View File

@@ -756,6 +756,22 @@ describe("FidoAuthenticatorService", () => {
});
});
describe("silentCredentialDiscovery", () => {
it("returns the fido2Credentials of a cipher found by its rpId", async () => {
const credentialId = Utils.newGuid();
const cipher = await createCipherView(
{ type: CipherType.Login },
{ credentialId, rpId: RpId, discoverable: true },
);
const ciphers = [cipher];
cipherService.getAllDecrypted.mockResolvedValue(ciphers);
const result = await authenticator.silentCredentialDiscovery(RpId);
expect(result).toEqual([cipher.login.fido2Credentials[0]]);
});
});
async function createParams(
params: Partial<Fido2AuthenticatorGetAssertionParams> = {},
): Promise<Fido2AuthenticatorGetAssertionParams> {

View File

@@ -234,10 +234,14 @@ export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstr
throw new Fido2AuthenticatorError(Fido2AuthenticatorErrorCode.NotAllowed);
}
const response = await userInterfaceSession.pickCredential({
cipherIds: cipherOptions.map((cipher) => cipher.id),
userVerification: params.requireUserVerification,
});
let response = { cipherId: cipherOptions[0].id, userVerified: false };
if (this.requiresUserVerificationPrompt(params, cipherOptions)) {
response = await userInterfaceSession.pickCredential({
cipherIds: cipherOptions.map((cipher) => cipher.id),
userVerification: params.requireUserVerification,
});
}
const selectedCipherId = response.cipherId;
const userVerified = response.userVerified;
const selectedCipher = cipherOptions.find((c) => c.id === selectedCipherId);
@@ -310,6 +314,24 @@ export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstr
}
}
private requiresUserVerificationPrompt(
params: Fido2AuthenticatorGetAssertionParams,
cipherOptions: CipherView[],
): boolean {
return (
params.requireUserVerification ||
!params.assumeUserPresence ||
cipherOptions.length > 1 ||
cipherOptions.length === 0 ||
cipherOptions.some((cipher) => cipher.reprompt !== CipherRepromptType.None)
);
}
async silentCredentialDiscovery(rpId: string): Promise<Fido2CredentialView[]> {
const credentials = await this.findCredentialsByRp(rpId);
return credentials.map((c) => c.login.fido2Credentials[0]);
}
/** Finds existing crendetials and returns the `cipherId` for each one */
private async findExcludedCredentials(
credentials: PublicKeyCredentialDescriptor[],

View File

@@ -1,12 +1,17 @@
import { mock, MockProxy } from "jest-mock-extended";
import { of } from "rxjs";
import { BehaviorSubject, of } from "rxjs";
import { AuthService } from "../../../auth/abstractions/auth.service";
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
import { DomainSettingsService } from "../../../autofill/services/domain-settings.service";
import { Utils } from "../../../platform/misc/utils";
import { VaultSettingsService } from "../../../vault/abstractions/vault-settings/vault-settings.service";
import { Fido2CredentialView } from "../../../vault/models/view/fido2-credential.view";
import { ConfigService } from "../../abstractions/config/config.service";
import {
ActiveRequest,
Fido2ActiveRequestManager,
} from "../../abstractions/fido2/fido2-active-request-manager.abstraction";
import {
Fido2AuthenticatorError,
Fido2AuthenticatorErrorCode,
@@ -37,6 +42,8 @@ describe("FidoAuthenticatorService", () => {
let vaultSettingsService: MockProxy<VaultSettingsService>;
let domainSettingsService: MockProxy<DomainSettingsService>;
let taskSchedulerService: MockProxy<TaskSchedulerService>;
let activeRequest!: MockProxy<ActiveRequest>;
let requestManager!: MockProxy<Fido2ActiveRequestManager>;
let client!: Fido2ClientService;
let tab!: chrome.tabs.Tab;
let isValidRpId!: jest.SpyInstance;
@@ -48,6 +55,13 @@ describe("FidoAuthenticatorService", () => {
vaultSettingsService = mock<VaultSettingsService>();
domainSettingsService = mock<DomainSettingsService>();
taskSchedulerService = mock<TaskSchedulerService>();
activeRequest = mock<ActiveRequest>({
subject: new BehaviorSubject<string>(""),
});
requestManager = mock<Fido2ActiveRequestManager>({
getActiveRequest$: (tabId: number) => new BehaviorSubject(activeRequest),
getActiveRequest: (tabId: number) => activeRequest,
});
isValidRpId = jest.spyOn(DomainUtils, "isValidRpId");
@@ -58,11 +72,12 @@ describe("FidoAuthenticatorService", () => {
vaultSettingsService,
domainSettingsService,
taskSchedulerService,
requestManager,
);
configService.serverConfig$ = of({ environment: { vault: VaultUrl } } as any);
vaultSettingsService.enablePasskeys$ = of(true);
domainSettingsService.neverDomains$ = of({});
authService.getAuthStatus.mockResolvedValue(AuthenticationStatus.Unlocked);
authService.activeAccountStatus$ = of(AuthenticationStatus.Unlocked);
tab = { id: 123, windowId: 456 } as chrome.tabs.Tab;
});
@@ -592,6 +607,50 @@ describe("FidoAuthenticatorService", () => {
});
});
describe("assert mediated conditional ui credential", () => {
const params = createParams({
userVerification: "required",
mediation: "conditional",
allowedCredentialIds: [],
});
beforeEach(() => {
requestManager.newActiveRequest.mockResolvedValue(crypto.randomUUID());
authenticator.getAssertion.mockResolvedValue(createAuthenticatorAssertResult());
});
it("creates an active mediated conditional request", async () => {
await client.assertCredential(params, tab);
expect(requestManager.newActiveRequest).toHaveBeenCalled();
expect(authenticator.getAssertion).toHaveBeenCalledWith(
expect.objectContaining({
assumeUserPresence: true,
rpId: RpId,
}),
tab,
);
});
it("restarts the mediated conditional request if a user aborts the request", async () => {
authenticator.getAssertion.mockRejectedValueOnce(new Error());
await client.assertCredential(params, tab);
expect(authenticator.getAssertion).toHaveBeenCalledTimes(2);
});
it("restarts the mediated conditional request if a the abort controller aborts the request", async () => {
const abortController = new AbortController();
abortController.abort();
authenticator.getAssertion.mockRejectedValueOnce(new DOMException("AbortError"));
await client.assertCredential(params, tab);
expect(authenticator.getAssertion).toHaveBeenCalledTimes(2);
});
});
function createParams(params: Partial<AssertCredentialParams> = {}): AssertCredentialParams {
return {
allowedCredentialIds: params.allowedCredentialIds ?? [],
@@ -602,6 +661,7 @@ describe("FidoAuthenticatorService", () => {
userVerification: params.userVerification,
sameOriginWithAncestors: true,
fallbackSupported: params.fallbackSupported ?? false,
mediation: params.mediation,
};
}
@@ -616,6 +676,28 @@ describe("FidoAuthenticatorService", () => {
};
}
});
describe("autofill of credentials through the active request manager", () => {
it("returns an observable that updates with an array of the credentials for active Fido2 requests", async () => {
const activeRequestCredentials = mock<Fido2CredentialView>();
activeRequest.credentials = [activeRequestCredentials];
const observable = client.availableAutofillCredentials$(tab.id);
observable.subscribe((credentials) => {
expect(credentials).toEqual([activeRequestCredentials]);
});
});
it("triggers the logic of the next behavior subject of an active request", async () => {
const activeRequestCredentials = mock<Fido2CredentialView>();
activeRequest.credentials = [activeRequestCredentials];
jest.spyOn(activeRequest.subject, "next");
await client.autofillCredential(tab.id, activeRequestCredentials.credentialId);
expect(activeRequest.subject.next).toHaveBeenCalled();
});
});
});
/** This is a fake function that always returns the same byte sequence */

View File

@@ -1,15 +1,18 @@
import { firstValueFrom, Subscription } from "rxjs";
import { firstValueFrom, map, Observable, Subscription } from "rxjs";
import { parse } from "tldts";
import { AuthService } from "../../../auth/abstractions/auth.service";
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
import { DomainSettingsService } from "../../../autofill/services/domain-settings.service";
import { VaultSettingsService } from "../../../vault/abstractions/vault-settings/vault-settings.service";
import { Fido2CredentialView } from "../../../vault/models/view/fido2-credential.view";
import { ConfigService } from "../../abstractions/config/config.service";
import { Fido2ActiveRequestManager } from "../../abstractions/fido2/fido2-active-request-manager.abstraction";
import {
Fido2AuthenticatorError,
Fido2AuthenticatorErrorCode,
Fido2AuthenticatorGetAssertionParams,
Fido2AuthenticatorGetAssertionResult,
Fido2AuthenticatorMakeCredentialsParams,
Fido2AuthenticatorService,
PublicKeyCredentialDescriptor,
@@ -32,6 +35,7 @@ import { TaskSchedulerService } from "../../scheduling/task-scheduler.service";
import { isValidRpId } from "./domain-utils";
import { Fido2Utils } from "./fido2-utils";
import { guidToRawFormat } from "./guid-utils";
/**
* Bitwarden implementation of the Web Authentication API as described by W3C
@@ -61,6 +65,7 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction {
private vaultSettingsService: VaultSettingsService,
private domainSettingsService: DomainSettingsService,
private taskSchedulerService: TaskSchedulerService,
private requestManager: Fido2ActiveRequestManager,
private logService?: LogService,
) {
this.taskSchedulerService.registerTaskHandler(ScheduledTaskNames.fido2ClientAbortTimeout, () =>
@@ -68,6 +73,17 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction {
);
}
availableAutofillCredentials$(tabId: number): Observable<Fido2CredentialView[]> {
return this.requestManager
.getActiveRequest$(tabId)
.pipe(map((request) => request?.credentials ?? []));
}
async autofillCredential(tabId: number, credentialId: string) {
const request = this.requestManager.getActiveRequest(tabId);
request.subject.next(credentialId);
}
async isFido2FeatureEnabled(hostname: string, origin: string): Promise<boolean> {
const isUserLoggedIn =
(await this.authService.getAuthStatus()) !== AuthenticationStatus.LoggedOut;
@@ -287,6 +303,16 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction {
};
const clientDataJSON = JSON.stringify(collectedClientData);
const clientDataJSONBytes = Utils.fromByteStringToArray(clientDataJSON);
if (params.mediation === "conditional") {
return this.handleMediatedConditionalRequest(
params,
tab,
abortController,
clientDataJSONBytes,
);
}
const clientDataHash = await crypto.subtle.digest({ name: "SHA-256" }, clientDataJSONBytes);
const getAssertionParams = mapToGetAssertionParams({ params, clientDataHash });
@@ -339,6 +365,59 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction {
timeoutSubscription?.unsubscribe();
return this.generateAssertCredentialResult(getAssertionResult, clientDataJSONBytes);
}
private async handleMediatedConditionalRequest(
params: AssertCredentialParams,
tab: chrome.tabs.Tab,
abortController: AbortController,
clientDataJSONBytes: Uint8Array,
): Promise<AssertCredentialResult> {
let getAssertionResult;
let assumeUserPresence = false;
while (!getAssertionResult) {
const authStatus = await firstValueFrom(this.authService.activeAccountStatus$);
const availableCredentials =
authStatus === AuthenticationStatus.Unlocked
? await this.authenticator.silentCredentialDiscovery(params.rpId)
: [];
this.logService?.info(
`[Fido2Client] started mediated request, available credentials: ${availableCredentials.length}`,
);
const credentialId = await this.requestManager.newActiveRequest(
tab.id,
availableCredentials,
abortController,
);
params.allowedCredentialIds = [Fido2Utils.bufferToString(guidToRawFormat(credentialId))];
assumeUserPresence = true;
const clientDataHash = await crypto.subtle.digest({ name: "SHA-256" }, clientDataJSONBytes);
const getAssertionParams = mapToGetAssertionParams({
params,
clientDataHash,
assumeUserPresence,
});
try {
getAssertionResult = await this.authenticator.getAssertion(getAssertionParams, tab);
} catch (e) {
this.logService?.info(`[Fido2Client] Aborted by user: ${e}`);
}
if (abortController.signal.aborted) {
this.logService?.info(`[Fido2Client] Aborted with AbortController`);
}
}
return this.generateAssertCredentialResult(getAssertionResult, clientDataJSONBytes);
}
private generateAssertCredentialResult(
getAssertionResult: Fido2AuthenticatorGetAssertionResult,
clientDataJSONBytes: Uint8Array,
): AssertCredentialResult {
return {
authenticatorData: Fido2Utils.bufferToString(getAssertionResult.authenticatorData),
clientDataJSON: Fido2Utils.bufferToString(clientDataJSONBytes),
@@ -431,9 +510,11 @@ function mapToMakeCredentialParams({
function mapToGetAssertionParams({
params,
clientDataHash,
assumeUserPresence,
}: {
params: AssertCredentialParams;
clientDataHash: ArrayBuffer;
assumeUserPresence?: boolean;
}): Fido2AuthenticatorGetAssertionParams {
const allowCredentialDescriptorList: PublicKeyCredentialDescriptor[] =
params.allowedCredentialIds.map((id) => ({
@@ -453,5 +534,6 @@ function mapToGetAssertionParams({
allowCredentialDescriptorList,
extensions: {},
fallbackSupported: params.fallbackSupported,
assumeUserPresence,
};
}

View File

@@ -25,9 +25,12 @@ export type ClientLocations = {
/**
* Overriding storage location for browser clients.
*
* "memory-large-object" is used to store non-countable objects in memory. This exists due to limited persistent memory available to browser extensions.
* `"memory-large-object"` is used to store non-countable objects in memory. This exists due to limited persistent memory available to browser extensions.
*
* `"disk-backup-local-storage"` is used to store object in both disk and in `localStorage`. Data is stored in both locations but is only retrieved
* from `localStorage` when a null-ish value is retrieved from disk first.
*/
browser: StorageLocation | "memory-large-object";
browser: StorageLocation | "memory-large-object" | "disk-backup-local-storage";
/**
* Overriding storage location for desktop clients.
*/

View File

@@ -46,6 +46,7 @@ export const AUTH_REQUEST_DISK_LOCAL = new StateDefinition("authRequestLocal", "
export const AVATAR_DISK = new StateDefinition("avatar", "disk", { web: "disk-local" });
export const DEVICE_TRUST_DISK_LOCAL = new StateDefinition("deviceTrust", "disk", {
web: "disk-local",
browser: "disk-backup-local-storage",
});
export const KDF_CONFIG_DISK = new StateDefinition("kdfConfig", "disk");
export const KEY_CONNECTOR_DISK = new StateDefinition("keyConnector", "disk");
@@ -110,6 +111,9 @@ export const CRYPTO_MEMORY = new StateDefinition("crypto", "memory");
export const DESKTOP_SETTINGS_DISK = new StateDefinition("desktopSettings", "disk");
export const ENVIRONMENT_DISK = new StateDefinition("environment", "disk");
export const ENVIRONMENT_MEMORY = new StateDefinition("environment", "memory");
export const POPUP_VIEW_MEMORY = new StateDefinition("popupView", "memory", {
browser: "memory-large-object",
});
export const SYNC_DISK = new StateDefinition("sync", "disk", { web: "memory" });
export const THEMING_DISK = new StateDefinition("theming", "disk", { web: "disk-local" });
export const TRANSLATION_DISK = new StateDefinition("translation", "disk", { web: "disk-local" });

View File

@@ -0,0 +1,59 @@
import { AbstractStorageService, ObservableStorageService } from "../abstractions/storage.service";
import { StorageOptions } from "../models/domain/storage-options";
export class PrimarySecondaryStorageService
implements AbstractStorageService, ObservableStorageService
{
// Only follow the primary storage service as updates should all be done to both
updates$ = this.primaryStorageService.updates$;
constructor(
private readonly primaryStorageService: AbstractStorageService & ObservableStorageService,
// Secondary service doesn't need to be observable as the only `updates$` are listened to from the primary store
private readonly secondaryStorageService: AbstractStorageService,
) {
if (
primaryStorageService.valuesRequireDeserialization !==
secondaryStorageService.valuesRequireDeserialization
) {
throw new Error(
"Differing values for valuesRequireDeserialization between storage services is not supported.",
);
}
}
get valuesRequireDeserialization(): boolean {
return this.primaryStorageService.valuesRequireDeserialization;
}
async get<T>(key: string, options?: StorageOptions): Promise<T> {
const primaryValue = await this.primaryStorageService.get<T>(key, options);
// If it's null-ish try the secondary location for its value
if (primaryValue == null) {
return await this.secondaryStorageService.get<T>(key, options);
}
return primaryValue;
}
async has(key: string, options?: StorageOptions): Promise<boolean> {
return (
(await this.primaryStorageService.has(key, options)) ||
(await this.secondaryStorageService.has(key, options))
);
}
async save<T>(key: string, obj: T, options?: StorageOptions): Promise<void> {
await Promise.allSettled([
this.primaryStorageService.save(key, obj, options),
this.secondaryStorageService.save(key, obj, options),
]);
}
async remove(key: string, options?: StorageOptions): Promise<void> {
await Promise.allSettled([
this.primaryStorageService.remove(key, options),
this.secondaryStorageService.remove(key, options),
]);
}
}

View File

@@ -0,0 +1,57 @@
import { Observable, Subject } from "rxjs";
import {
AbstractStorageService,
ObservableStorageService,
StorageUpdate,
} from "../abstractions/storage.service";
import { StorageOptions } from "../models/domain/storage-options";
export class WindowStorageService implements AbstractStorageService, ObservableStorageService {
private readonly updatesSubject = new Subject<StorageUpdate>();
updates$: Observable<StorageUpdate>;
constructor(private readonly storage: Storage) {
this.updates$ = this.updatesSubject.asObservable();
}
get valuesRequireDeserialization(): boolean {
return true;
}
get<T>(key: string, options?: StorageOptions): Promise<T> {
const jsonValue = this.storage.getItem(key);
if (jsonValue != null) {
return Promise.resolve(JSON.parse(jsonValue) as T);
}
return Promise.resolve(null);
}
async has(key: string, options?: StorageOptions): Promise<boolean> {
return (await this.get(key, options)) != null;
}
save<T>(key: string, obj: T, options?: StorageOptions): Promise<void> {
if (obj == null) {
return this.remove(key, options);
}
if (obj instanceof Set) {
obj = Array.from(obj) as T;
}
this.storage.setItem(key, JSON.stringify(obj));
this.updatesSubject.next({ key, updateType: "save" });
}
remove(key: string, options?: StorageOptions): Promise<void> {
this.storage.removeItem(key);
this.updatesSubject.next({ key, updateType: "remove" });
return Promise.resolve();
}
getKeys(): string[] {
return Object.keys(this.storage);
}
}

View File

@@ -127,6 +127,38 @@ describe("VaultTimeoutSettingsService", () => {
});
});
describe("canLock", () => {
it("returns true if the user can lock", async () => {
jest
.spyOn(vaultTimeoutSettingsService, "availableVaultTimeoutActions$")
.mockReturnValue(of([VaultTimeoutAction.Lock]));
const result = await vaultTimeoutSettingsService.canLock("userId" as UserId);
expect(result).toBe(true);
});
it("returns false if the user only has the log out vault timeout action", async () => {
jest
.spyOn(vaultTimeoutSettingsService, "availableVaultTimeoutActions$")
.mockReturnValue(of([VaultTimeoutAction.LogOut]));
const result = await vaultTimeoutSettingsService.canLock("userId" as UserId);
expect(result).toBe(false);
});
it("returns false if the user has no vault timeout actions", async () => {
jest
.spyOn(vaultTimeoutSettingsService, "availableVaultTimeoutActions$")
.mockReturnValue(of([]));
const result = await vaultTimeoutSettingsService.canLock("userId" as UserId);
expect(result).toBe(false);
});
});
describe("getVaultTimeoutActionByUserId$", () => {
it("should throw an error if no user id is provided", async () => {
expect(() => vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$(null)).toThrow(

View File

@@ -90,10 +90,17 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
await this.cryptoService.refreshAdditionalKeys();
}
availableVaultTimeoutActions$(userId?: string) {
availableVaultTimeoutActions$(userId?: string): Observable<VaultTimeoutAction[]> {
return defer(() => this.getAvailableVaultTimeoutActions(userId));
}
async canLock(userId: UserId): Promise<boolean> {
const availableVaultTimeoutActions: VaultTimeoutAction[] = await firstValueFrom(
this.availableVaultTimeoutActions$(userId),
);
return availableVaultTimeoutActions?.includes(VaultTimeoutAction.Lock) || false;
}
async isBiometricLockSet(userId?: string): Promise<boolean> {
const biometricUnlockPromise =
userId == null

View File

@@ -38,11 +38,7 @@ export class CollectionView implements View, ITreeNodeObject {
}
}
canEditItems(
org: Organization,
v1FlexibleCollections: boolean,
restrictProviderAccess: boolean,
): boolean {
canEditItems(org: Organization, restrictProviderAccess: boolean): boolean {
if (org != null && org.id !== this.organizationId) {
throw new Error(
"Id of the organization provided does not match the org id of the collection.",
@@ -50,7 +46,7 @@ export class CollectionView implements View, ITreeNodeObject {
}
return (
org?.canEditAllCiphers(v1FlexibleCollections, restrictProviderAccess) ||
org?.canEditAllCiphers(restrictProviderAccess) ||
this.manage ||
(this.assigned && !this.readOnly)
);
@@ -58,28 +54,23 @@ export class CollectionView implements View, ITreeNodeObject {
/**
* Returns true if the user can edit a collection (including user and group access) from the individual vault.
* After FCv1, does not include admin permissions - see {@link CollectionAdminView.canEdit}.
* Does not include admin permissions - see {@link CollectionAdminView.canEdit}.
*/
canEdit(org: Organization, flexibleCollectionsV1Enabled: boolean): boolean {
canEdit(org: Organization): boolean {
if (org != null && org.id !== this.organizationId) {
throw new Error(
"Id of the organization provided does not match the org id of the collection.",
);
}
if (flexibleCollectionsV1Enabled) {
// Only use individual permissions, not admin permissions
return this.manage;
}
return org?.canEditAnyCollection(flexibleCollectionsV1Enabled) || this.manage;
return this.manage;
}
/**
* Returns true if the user can delete a collection from the individual vault.
* After FCv1, does not include admin permissions - see {@link CollectionAdminView.canDelete}.
* Does not include admin permissions - see {@link CollectionAdminView.canDelete}.
*/
canDelete(org: Organization, flexibleCollectionsV1Enabled: boolean): boolean {
canDelete(org: Organization): boolean {
if (org != null && org.id !== this.organizationId) {
throw new Error(
"Id of the organization provided does not match the org id of the collection.",
@@ -88,24 +79,14 @@ export class CollectionView implements View, ITreeNodeObject {
const canDeleteManagedCollections = !org?.limitCollectionCreationDeletion || org.isAdmin;
if (flexibleCollectionsV1Enabled) {
// Only use individual permissions, not admin permissions
return canDeleteManagedCollections && this.manage;
}
return (
org?.canDeleteAnyCollection(flexibleCollectionsV1Enabled) ||
(canDeleteManagedCollections && this.manage)
);
// Only use individual permissions, not admin permissions
return canDeleteManagedCollections && this.manage;
}
/**
* Returns true if the user can view collection info and access in a read-only state from the individual vault
*/
canViewCollectionInfo(
org: Organization | undefined,
flexibleCollectionsV1Enabled: boolean,
): boolean {
canViewCollectionInfo(org: Organization | undefined): boolean {
return false;
}

View File

@@ -1,6 +1,7 @@
import { Component, HostListener, Input, booleanAttribute, signal } from "@angular/core";
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms";
import { compareValues } from "../../../common/src/platform/misc/compare-values";
import { ButtonModule } from "../button";
import { IconButtonModule } from "../icon-button";
import { MenuModule } from "../menu";
@@ -108,7 +109,7 @@ export class ChipSelectComponent<T = unknown> implements ControlValueAccessor {
*/
private findOption(tree: ChipSelectOption<T>, value: T): ChipSelectOption<T> | null {
let result = null;
if (tree.value !== null && tree.value === value) {
if (tree.value !== null && compareValues(tree.value, value)) {
return tree;
}

View File

@@ -1,5 +1,3 @@
export * from "./deactivated-org";
export * from "./search";
export * from "./no-access";
export * from "./vault";
export * from "./no-results";

View File

@@ -100,13 +100,9 @@
</ng-template>
<div
#endSlot
*ngIf="data.open"
class="tw-flex -tw-ml-3 tw-pr-4 tw-gap-1 [&>*:focus-visible::before]:!tw-ring-text-alt2 [&>*:hover]:!tw-border-text-alt2 [&>*]:tw-text-alt2"
[ngClass]="[
variant === 'tree' ? 'tw-py-1' : 'tw-py-2',
endSlot.childElementCount === 0 ? 'tw-hidden' : '',
]"
class="tw-flex -tw-ml-3 tw-pr-4 tw-gap-1 [&>*:focus-visible::before]:!tw-ring-text-alt2 [&>*:hover]:!tw-border-text-alt2 [&>*]:tw-text-alt2 empty:tw-hidden"
[ngClass]="[variant === 'tree' ? 'tw-py-1' : 'tw-py-2']"
>
<ng-content select="[slot=end]"></ng-content>
</div>

View File

@@ -9,6 +9,12 @@
[attr.aria-disabled]="disabled"
ariaCurrentWhenActive="page"
role="link"
class="tw-flex tw-group/tab hover:tw-no-underline"
>
<ng-content></ng-content>
<div class="group-hover/tab:tw-underline">
<ng-content></ng-content>
</div>
<div class="tw-font-normal tw-ml-2 empty:tw-ml-0">
<ng-content select="[slot=end]"></ng-content>
</div>
</a>

View File

@@ -33,6 +33,12 @@ class ItemThreeDummyComponent {}
})
class DisabledDummyComponent {}
@Component({
selector: "bit-tab-item-with-child-counter-dummy",
template: "Router - Item With Child Counter selected",
})
class ItemWithChildCounterDummyComponent {}
export default {
title: "Component Library/Tabs",
component: TabGroupComponent,
@@ -42,6 +48,7 @@ export default {
ActiveDummyComponent,
ItemTwoDummyComponent,
ItemThreeDummyComponent,
ItemWithChildCounterDummyComponent,
DisabledDummyComponent,
],
imports: [CommonModule, TabsModule, ButtonModule, FormFieldModule, RouterModule],
@@ -55,6 +62,7 @@ export default {
{ path: "active", component: ActiveDummyComponent },
{ path: "item-2", component: ItemTwoDummyComponent },
{ path: "item-3", component: ItemThreeDummyComponent },
{ path: "item-with-child-counter", component: ItemWithChildCounterDummyComponent },
{ path: "disabled", component: DisabledDummyComponent },
],
{ useHash: true },
@@ -102,6 +110,12 @@ export const NavigationTabs: Story = {
<bit-tab-link [route]="['active']">Active</bit-tab-link>
<bit-tab-link [route]="['item-2']">Item 2</bit-tab-link>
<bit-tab-link [route]="['item-3']">Item 3</bit-tab-link>
<bit-tab-link [route]="['item-with-child-counter']">
Item With Counter
<div slot="end" class="tw-pl-2 tw-text-muted">
42
</div>
</bit-tab-link>
<bit-tab-link [route]="['disable']" [disabled]="true">Disabled</bit-tab-link>
</bit-tab-nav-bar>
<div class="tw-bg-transparent tw-text-semibold tw-text-center tw-text-main tw-py-10">

View File

@@ -31,8 +31,8 @@ describe("Protonpass Json Importer", () => {
expect(uriView.uri).toEqual("https://example.com/");
expect(cipher.notes).toEqual("My login secure note.");
expect(cipher.fields.at(0).name).toEqual("itemUsername");
expect(cipher.fields.at(0).value).toEqual("someOtherUsername");
expect(cipher.fields.at(0).name).toEqual("email");
expect(cipher.fields.at(0).value).toEqual("Email");
expect(cipher.fields.at(3).name).toEqual("second 2fa secret");
expect(cipher.fields.at(3).value).toEqual("TOTPCODE");

View File

@@ -49,12 +49,12 @@ export const testData: ProtonPassJsonFile = {
],
type: "login",
content: {
itemEmail: "Username",
itemEmail: "Email",
password: "Password",
urls: ["https://example.com/", "https://example2.com/"],
totpUri:
"otpauth://totp/Test%20Login%20-%20Personal%20Vault:Username?issuer=Test%20Login%20-%20Personal%20Vault&secret=TOTPCODE&algorithm=SHA1&digits=6&period=30",
itemUsername: "someOtherUsername",
itemUsername: "Username",
},
},
state: 1,
@@ -102,7 +102,7 @@ export const testData: ProtonPassJsonFile = {
cardType: 0,
number: "1234222233334444",
verificationNumber: "333",
expirationDate: "012025",
expirationDate: "2025-01",
pin: "1234",
},
},

View File

@@ -48,10 +48,17 @@ export class ProtonPassJsonImporter extends BaseImporter implements Importer {
case "login": {
const loginContent = item.data.content as ProtonPassLoginItemContent;
cipher.login.uris = this.makeUriArray(loginContent.urls);
cipher.login.username = this.getValueOrDefault(loginContent.itemEmail);
cipher.login.username = this.getValueOrDefault(loginContent.itemUsername);
// if the cipher has no username then the email is used as the username
if (cipher.login.username == null) {
cipher.login.username = this.getValueOrDefault(loginContent.itemEmail);
} else {
this.processKvp(cipher, "email", loginContent.itemEmail);
}
cipher.login.password = this.getValueOrDefault(loginContent.password);
cipher.login.totp = this.getValueOrDefault(loginContent.totpUri);
this.processKvp(cipher, "itemUsername", loginContent.itemUsername);
for (const extraField of item.data.extraFields) {
this.processKvp(
cipher,
@@ -77,9 +84,9 @@ export class ProtonPassJsonImporter extends BaseImporter implements Importer {
cipher.card.code = this.getValueOrDefault(creditCardContent.verificationNumber);
if (!this.isNullOrWhitespace(creditCardContent.expirationDate)) {
cipher.card.expMonth = creditCardContent.expirationDate.substring(0, 2);
cipher.card.expMonth = creditCardContent.expirationDate.substring(5, 7);
cipher.card.expMonth = cipher.card.expMonth.replace(/^0+/, "");
cipher.card.expYear = creditCardContent.expirationDate.substring(2, 6);
cipher.card.expYear = creditCardContent.expirationDate.substring(0, 4);
}
if (!this.isNullOrWhitespace(creditCardContent.pin)) {

View File

@@ -0,0 +1,5 @@
/** Types of passwords that may be configured by the password generator */
export const PasswordTypes = Object.freeze(["password", "passphrase"] as const);
/** Types of generators that may be configured by the password generator */
export const GeneratorTypes = Object.freeze([...PasswordTypes, "username"] as const);

View File

@@ -17,3 +17,4 @@ export * from "./forwarders";
export * from "./integrations";
export * from "./policies";
export * from "./username-digits";
export * from "./generator-types";

View File

@@ -1,2 +1,7 @@
import { GeneratorTypes, PasswordTypes } from "../data/generator-types";
/** The kind of credential being generated. */
export type GeneratorType = "password" | "passphrase" | "username";
export type GeneratorType = (typeof GeneratorTypes)[number];
/** The kinds of passwords that can be generated. */
export type PasswordType = (typeof PasswordTypes)[number];

View File

@@ -270,7 +270,7 @@ describe("LegacyPasswordGenerationService", () => {
const navigation = createNavigationGenerator(
{},
{
defaultType: "password",
overridePasswordType: "password",
},
);
const generator = new LegacyPasswordGenerationService(
@@ -284,7 +284,7 @@ describe("LegacyPasswordGenerationService", () => {
const [, policy] = await generator.getOptions();
expect(policy).toEqual({
defaultType: "password",
overridePasswordType: "password",
minLength: 20,
numberCount: 10,
specialCount: 11,
@@ -402,7 +402,7 @@ describe("LegacyPasswordGenerationService", () => {
const navigation = createNavigationGenerator(
{},
{
defaultType: "password",
overridePasswordType: "password",
},
);
const generator = new LegacyPasswordGenerationService(
@@ -416,7 +416,7 @@ describe("LegacyPasswordGenerationService", () => {
const [, policy] = await generator.enforcePasswordGeneratorPoliciesOnOptions({});
expect(policy).toEqual({
defaultType: "password",
overridePasswordType: "password",
minLength: 20,
numberCount: 10,
specialCount: 11,

View File

@@ -248,7 +248,7 @@ export class LegacyPasswordGenerationService implements PasswordGenerationServic
...options,
...navigationEvaluator.sanitize(navigationApplied),
};
if (options.type === "password") {
if (navigationSanitized.type === "password") {
const applied = passwordEvaluator.applyPolicy(navigationSanitized);
const sanitized = passwordEvaluator.sanitize(applied);
return [sanitized, policy];

View File

@@ -69,7 +69,7 @@ describe("DefaultGeneratorNavigationService", () => {
organizationId: "" as any,
enabled: true,
type: PolicyType.PasswordGenerator,
data: { defaultType: "password" },
data: { overridePasswordType: "password" },
}),
]);
},

View File

@@ -4,18 +4,18 @@ import { GeneratorNavigationEvaluator } from "./generator-navigation-evaluator";
describe("GeneratorNavigationEvaluator", () => {
describe("policyInEffect", () => {
it.each([["passphrase"], ["password"]] as const)(
"returns true if the policy has a defaultType (= %p)",
(defaultType) => {
const evaluator = new GeneratorNavigationEvaluator({ defaultType });
"returns true if the policy has a overridePasswordType (= %p)",
(overridePasswordType) => {
const evaluator = new GeneratorNavigationEvaluator({ overridePasswordType });
expect(evaluator.policyInEffect).toEqual(true);
},
);
it.each([[undefined], [null], ["" as any]])(
"returns false if the policy has a falsy defaultType (= %p)",
(defaultType) => {
const evaluator = new GeneratorNavigationEvaluator({ defaultType });
"returns false if the policy has a falsy overridePasswordType (= %p)",
(overridePasswordType) => {
const evaluator = new GeneratorNavigationEvaluator({ overridePasswordType });
expect(evaluator.policyInEffect).toEqual(false);
},
@@ -23,7 +23,7 @@ describe("GeneratorNavigationEvaluator", () => {
});
describe("applyPolicy", () => {
it("returns the input options", () => {
it("returns the input options when a policy is not in effect", () => {
const evaluator = new GeneratorNavigationEvaluator(null);
const options = { type: "password" as const };
@@ -31,19 +31,27 @@ describe("GeneratorNavigationEvaluator", () => {
expect(result).toEqual(options);
});
it.each([["passphrase"], ["password"]] as const)(
"defaults options to the policy's default type (= %p) when a policy is in effect",
(overridePasswordType) => {
const evaluator = new GeneratorNavigationEvaluator({ overridePasswordType });
const result = evaluator.applyPolicy({});
expect(result).toEqual({ type: overridePasswordType });
},
);
});
describe("sanitize", () => {
it.each([["passphrase"], ["password"]] as const)(
"defaults options to the policy's default type (= %p) when a policy is in effect",
(defaultType) => {
const evaluator = new GeneratorNavigationEvaluator({ defaultType });
it("retains the options type when it is set", () => {
const evaluator = new GeneratorNavigationEvaluator({ overridePasswordType: "passphrase" });
const result = evaluator.sanitize({});
const result = evaluator.sanitize({ type: "password" });
expect(result).toEqual({ type: defaultType });
},
);
expect(result).toEqual({ type: "password" });
});
it("defaults options to the default generator navigation type when a policy is not in effect", () => {
const evaluator = new GeneratorNavigationEvaluator(null);
@@ -52,13 +60,5 @@ describe("GeneratorNavigationEvaluator", () => {
expect(result.type).toEqual(DefaultGeneratorNavigation.type);
});
it("retains the options type when it is set", () => {
const evaluator = new GeneratorNavigationEvaluator({ defaultType: "passphrase" });
const result = evaluator.sanitize({ type: "password" });
expect(result).toEqual({ type: "password" });
});
});
});

View File

@@ -1,4 +1,4 @@
import { PolicyEvaluator } from "@bitwarden/generator-core";
import { PasswordTypes, PolicyEvaluator } from "@bitwarden/generator-core";
import { DefaultGeneratorNavigation } from "./default-generator-navigation";
import { GeneratorNavigation } from "./generator-navigation";
@@ -17,7 +17,7 @@ export class GeneratorNavigationEvaluator
/** {@link PolicyEvaluator.policyInEffect} */
get policyInEffect(): boolean {
return this.policy?.defaultType ? true : false;
return PasswordTypes.includes(this.policy?.overridePasswordType);
}
/** Apply policy to the input options.
@@ -25,7 +25,13 @@ export class GeneratorNavigationEvaluator
* @returns A new password generation request with policy applied.
*/
applyPolicy(options: GeneratorNavigation): GeneratorNavigation {
return options;
const result = { ...options };
if (this.policyInEffect) {
result.type = this.policy.overridePasswordType ?? result.type;
}
return result;
}
/** Ensures internal options consistency.
@@ -33,12 +39,9 @@ export class GeneratorNavigationEvaluator
* @returns A passphrase generation request with cascade applied.
*/
sanitize(options: GeneratorNavigation): GeneratorNavigation {
const defaultType = this.policyInEffect
? this.policy.defaultType
: DefaultGeneratorNavigation.type;
return {
...options,
type: options.type ?? defaultType,
type: options.type ?? DefaultGeneratorNavigation.type,
};
}
}

View File

@@ -38,26 +38,26 @@ describe("leastPrivilege", () => {
});
it("should take the %p from the policy", () => {
const policy = createPolicy({ defaultType: "passphrase" });
const policy = createPolicy({ overridePasswordType: "passphrase" });
const result = preferPassword({ ...DisabledGeneratorNavigationPolicy }, policy);
expect(result).toEqual({ defaultType: "passphrase" });
expect(result).toEqual({ overridePasswordType: "passphrase" });
});
it("should override passphrase with password", () => {
const policy = createPolicy({ defaultType: "password" });
const policy = createPolicy({ overridePasswordType: "password" });
const result = preferPassword({ defaultType: "passphrase" }, policy);
const result = preferPassword({ overridePasswordType: "passphrase" }, policy);
expect(result).toEqual({ defaultType: "password" });
expect(result).toEqual({ overridePasswordType: "password" });
});
it("should not override password", () => {
const policy = createPolicy({ defaultType: "passphrase" });
const policy = createPolicy({ overridePasswordType: "passphrase" });
const result = preferPassword({ defaultType: "password" }, policy);
const result = preferPassword({ overridePasswordType: "password" }, policy);
expect(result).toEqual({ defaultType: "password" });
expect(result).toEqual({ overridePasswordType: "password" });
});
});

View File

@@ -2,14 +2,14 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums";
// FIXME: use index.ts imports once policy abstractions and models
// implement ADR-0002
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { GeneratorType } from "@bitwarden/generator-core";
import { PasswordType } from "@bitwarden/generator-core";
/** Policy settings affecting password generator navigation */
export type GeneratorNavigationPolicy = {
/** The type of generator that should be shown by default when opening
* the password generator.
*/
defaultType?: GeneratorType;
overridePasswordType?: PasswordType;
};
/** Reduces a policy into an accumulator by preferring the password generator
@@ -27,13 +27,15 @@ export function preferPassword(
return acc;
}
const isOverridable = acc.defaultType !== "password" && policy.data.defaultType;
const result = isOverridable ? { ...acc, defaultType: policy.data.defaultType } : acc;
const isOverridable = acc.overridePasswordType !== "password" && policy.data.overridePasswordType;
const result = isOverridable
? { ...acc, overridePasswordType: policy.data.overridePasswordType }
: acc;
return result;
}
/** The default options for password generation policy. */
export const DisabledGeneratorNavigationPolicy: GeneratorNavigationPolicy = Object.freeze({
defaultType: undefined,
overridePasswordType: null,
});

View File

@@ -11,10 +11,4 @@ export abstract class CipherFormGenerationService {
* Generates a random username. Called when the user clicks the "Generate Username" button in the UI.
*/
abstract generateUsername(): Promise<string | null>;
/**
* Generates an initial password for a new cipher. This should not involve any user interaction as it will
* be used to pre-fill the password field in the UI for new Login ciphers.
*/
abstract generateInitialPassword(): Promise<string | null>;
}

View File

@@ -1,3 +1,8 @@
/**
* TODO: PM-10727 - Rename and Refactor this service
* This service is being used in both CipherForm and CipherView. Update this service to reflect that
*/
/**
* Service to capture TOTP secret from a client application.
*/
@@ -6,4 +11,5 @@ export abstract class TotpCaptureService {
* Captures a TOTP secret and returns it as a string. Returns null if no TOTP secret was found.
*/
abstract captureTotpSecret(): Promise<string | null>;
abstract openAutofillNewTab(loginUri: string): void;
}

View File

@@ -1,6 +1,6 @@
<bit-section [formGroup]="autofillOptionsForm">
<bit-section-header>
<h2 bitTypography="h5">
<h2 bitTypography="h6">
{{ "autofillOptions" | i18n }}
</h2>
</bit-section-header>
@@ -13,6 +13,7 @@
(remove)="removeUri(i)"
[canRemove]="uriControls.length > 1"
[defaultMatchDetection]="defaultMatchDetection$ | async"
[index]="i"
></vault-autofill-uri-option>
</ng-container>
@@ -20,7 +21,7 @@
type="button"
bitLink
linkType="primary"
class="tw-mb-6"
[class.tw-mb-6]="autofillOnPageLoadEnabled$ | async"
(click)="addUri({ uri: null, matchDetection: null }, true)"
*ngIf="autofillOptionsForm.enabled"
>
@@ -28,7 +29,7 @@
{{ "addWebsite" | i18n }}
</button>
<bit-form-field>
<bit-form-field *ngIf="autofillOnPageLoadEnabled$ | async" disableMargin>
<bit-label>{{ "autoFillOnPageLoad" | i18n }}</bit-label>
<bit-select formControlName="autofillOnPageLoad" [items]="autofillOptions"></bit-select>
</bit-form-field>

View File

@@ -32,6 +32,7 @@ describe("AutofillOptionsComponent", () => {
autofillSettingsService = mock<AutofillSettingsServiceAbstraction>();
autofillSettingsService.autofillOnPageLoadDefault$ = new BehaviorSubject(false);
autofillSettingsService.autofillOnPageLoad$ = new BehaviorSubject(true);
await TestBed.configureTestingModule({
imports: [AutofillOptionsComponent],
@@ -145,6 +146,22 @@ describe("AutofillOptionsComponent", () => {
expect(component["autofillOptions"][0].label).toEqual("defaultLabel yes");
});
it("hides the autofill on page load field when the setting is disabled", () => {
fixture.detectChanges();
let control = fixture.nativeElement.querySelector(
"bit-select[formControlName='autofillOnPageLoad']",
);
expect(control).toBeTruthy();
(autofillSettingsService.autofillOnPageLoad$ as BehaviorSubject<boolean>).next(false);
fixture.detectChanges();
control = fixture.nativeElement.querySelector(
"bit-select[formControlName='autofillOnPageLoad']",
);
expect(control).toBeFalsy();
});
it("announces the addition of a new URI input", fakeAsync(() => {
fixture.detectChanges();

View File

@@ -70,6 +70,7 @@ export class AutofillOptionsComponent implements OnInit {
}
protected defaultMatchDetection$ = this.domainSettingsService.defaultUriMatchStrategy$;
protected autofillOnPageLoadEnabled$ = this.autofillSettingsService.autofillOnPageLoad$;
protected autofillOptions: { label: string; value: boolean | null }[] = [
{ label: this.i18nService.t("default"), value: null },

View File

@@ -1,6 +1,6 @@
<ng-container [formGroup]="uriForm">
<bit-form-field>
<bit-label>{{ "websiteUri" | i18n }}</bit-label>
<bit-form-field [class.!tw-mb-1]="showMatchDetection">
<bit-label>{{ uriLabel }}</bit-label>
<input bitInput formControlName="uri" #uriInput />
<button
type="button"
@@ -22,7 +22,7 @@
></button>
</bit-form-field>
<bit-form-field *ngIf="showMatchDetection" class="!tw-mb-9 -tw-mt-4">
<bit-form-field *ngIf="showMatchDetection" class="!tw-mb-5">
<bit-label>{{ "matchDetection" | i18n }}</bit-label>
<bit-select formControlName="matchDetection" #matchDetectionSelect>
<bit-option

View File

@@ -89,6 +89,11 @@ export class UriOptionComponent implements ControlValueAccessor {
);
}
/**
* The index of the URI in the form. Used to render the correct label.
*/
@Input({ required: true }) index: number;
/**
* Emits when the remove button is clicked and URI should be removed from the form.
*/
@@ -104,6 +109,12 @@ export class UriOptionComponent implements ControlValueAccessor {
}
}
protected get uriLabel() {
return this.index === 0
? this.i18nService.t("websiteUri")
: this.i18nService.t("websiteUriCount", this.index + 1);
}
protected get toggleTitle() {
return this.showMatchDetection
? this.i18nService.t("hideMatchDetection", this.uriForm.value.uri)

View File

@@ -0,0 +1,62 @@
<bit-section>
<!-- Password/Passphrase Toggle -->
<bit-toggle-group
*ngIf="isPassword"
class="tw-w-full tw-justify-center tw-mt-3 tw-mb-5"
(selectedChange)="updatePasswordType($event)"
[selected]="passwordType$ | async"
>
<bit-toggle [value]="'password'">
{{ "password" | i18n }}
</bit-toggle>
<bit-toggle [value]="'passphrase'">
{{ "passphrase" | i18n }}
</bit-toggle>
</bit-toggle-group>
<!-- Generated Password/Passphrase/Username -->
<bit-item>
<bit-item-content>
<bit-color-password [password]="generatedValue"></bit-color-password>
</bit-item-content>
<ng-container slot="end">
<bit-item-action>
<button
type="button"
bitIconButton="bwi-clone"
size="small"
[appCopyClick]="generatedValue"
showToast
[appA11yTitle]="'copyValue' | i18n"
data-testid="copy-value-button"
></button>
</bit-item-action>
<bit-item-action>
<button
type="button"
bitIconButton="bwi-generate"
size="small"
(click)="regenerate$.next()"
[appA11yTitle]="regenerateButtonTitle"
data-testid="regenerate-button"
></button>
</bit-item-action>
</ng-container>
</bit-item>
</bit-section>
<!-- Generator Options -->
<!-- TODO: Replace with Generator Options Component(s) when available
It is expected that the generator options component(s) will internally update the options stored in state
which will trigger regeneration automatically in this dialog.
-->
<bit-section>
<bit-section-header>
<h2 bitTypography="h5">{{ "options" | i18n }}</h2>
</bit-section-header>
<bit-card>
<em bitTypography="body2"
>Placeholder: Replace with Generator Options Component(s) when available</em
>
</bit-card>
</bit-section>

View File

@@ -0,0 +1,210 @@
import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing";
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import {
PasswordGenerationServiceAbstraction,
PasswordGeneratorOptions,
UsernameGenerationServiceAbstraction,
UsernameGeneratorOptions,
} from "@bitwarden/generator-legacy";
import { CipherFormGeneratorComponent } from "@bitwarden/vault";
describe("CipherFormGeneratorComponent", () => {
let component: CipherFormGeneratorComponent;
let fixture: ComponentFixture<CipherFormGeneratorComponent>;
let mockLegacyPasswordGenerationService: MockProxy<PasswordGenerationServiceAbstraction>;
let mockLegacyUsernameGenerationService: MockProxy<UsernameGenerationServiceAbstraction>;
let mockPlatformUtilsService: MockProxy<PlatformUtilsService>;
let passwordOptions$: BehaviorSubject<any>;
let usernameOptions$: BehaviorSubject<any>;
beforeEach(async () => {
passwordOptions$ = new BehaviorSubject([
{
type: "password",
},
] as [PasswordGeneratorOptions]);
usernameOptions$ = new BehaviorSubject([
{
type: "word",
},
] as [UsernameGeneratorOptions]);
mockPlatformUtilsService = mock<PlatformUtilsService>();
mockLegacyPasswordGenerationService = mock<PasswordGenerationServiceAbstraction>();
mockLegacyPasswordGenerationService.getOptions$.mockReturnValue(passwordOptions$);
mockLegacyUsernameGenerationService = mock<UsernameGenerationServiceAbstraction>();
mockLegacyUsernameGenerationService.getOptions$.mockReturnValue(usernameOptions$);
await TestBed.configureTestingModule({
imports: [CipherFormGeneratorComponent],
providers: [
{ provide: I18nService, useValue: { t: (key: string) => key } },
{
provide: PasswordGenerationServiceAbstraction,
useValue: mockLegacyPasswordGenerationService,
},
{
provide: UsernameGenerationServiceAbstraction,
useValue: mockLegacyUsernameGenerationService,
},
{ provide: PlatformUtilsService, useValue: mockPlatformUtilsService },
],
}).compileComponents();
fixture = TestBed.createComponent(CipherFormGeneratorComponent);
component = fixture.componentInstance;
});
it("should create", () => {
fixture.detectChanges();
expect(component).toBeTruthy();
});
it("should use the appropriate text based on generator type", () => {
component.type = "password";
component.ngOnChanges();
expect(component["regenerateButtonTitle"]).toBe("regeneratePassword");
component.type = "username";
component.ngOnChanges();
expect(component["regenerateButtonTitle"]).toBe("regenerateUsername");
});
it("should emit regenerate$ when user clicks the regenerate button", fakeAsync(() => {
const regenerateSpy = jest.spyOn(component["regenerate$"], "next");
fixture.nativeElement.querySelector("button[data-testid='regenerate-button']").click();
expect(regenerateSpy).toHaveBeenCalled();
}));
it("should emit valueGenerated whenever a new value is generated", fakeAsync(() => {
const valueGeneratedSpy = jest.spyOn(component.valueGenerated, "emit");
mockLegacyPasswordGenerationService.generatePassword.mockResolvedValue("generated-password");
component.type = "password";
component.ngOnChanges();
tick();
expect(valueGeneratedSpy).toHaveBeenCalledWith("generated-password");
}));
describe("password generation", () => {
beforeEach(() => {
component.type = "password";
});
it("should update the generated value when the password options change", fakeAsync(() => {
mockLegacyPasswordGenerationService.generatePassword
.mockResolvedValueOnce("first-password")
.mockResolvedValueOnce("second-password");
component.ngOnChanges();
tick();
expect(component["generatedValue"]).toBe("first-password");
passwordOptions$.next([{ type: "password" }]);
tick();
expect(component["generatedValue"]).toBe("second-password");
expect(mockLegacyPasswordGenerationService.generatePassword).toHaveBeenCalledTimes(2);
}));
it("should show password type toggle when the generator type is password", () => {
fixture.detectChanges();
expect(fixture.nativeElement.querySelector("bit-toggle-group")).toBeTruthy();
});
it("should save password options when the password type is updated", async () => {
mockLegacyPasswordGenerationService.generatePassword.mockResolvedValue("generated-password");
await component["updatePasswordType"]("passphrase");
expect(mockLegacyPasswordGenerationService.saveOptions).toHaveBeenCalledWith({
type: "passphrase",
});
});
it("should update the password history when a new password is generated", fakeAsync(() => {
mockLegacyPasswordGenerationService.generatePassword.mockResolvedValue("new-password");
component.ngOnChanges();
tick();
expect(mockLegacyPasswordGenerationService.generatePassword).toHaveBeenCalledTimes(1);
expect(mockLegacyPasswordGenerationService.addHistory).toHaveBeenCalledWith("new-password");
expect(component["generatedValue"]).toBe("new-password");
}));
it("should regenerate the password when regenerate$ emits", fakeAsync(() => {
mockLegacyPasswordGenerationService.generatePassword
.mockResolvedValueOnce("first-password")
.mockResolvedValueOnce("second-password");
component.ngOnChanges();
tick();
expect(component["generatedValue"]).toBe("first-password");
component["regenerate$"].next();
tick();
expect(component["generatedValue"]).toBe("second-password");
}));
});
describe("username generation", () => {
beforeEach(() => {
component.type = "username";
});
it("should update the generated value when the username options change", fakeAsync(() => {
mockLegacyUsernameGenerationService.generateUsername
.mockResolvedValueOnce("first-username")
.mockResolvedValueOnce("second-username");
component.ngOnChanges();
tick();
expect(component["generatedValue"]).toBe("first-username");
usernameOptions$.next([{ type: "word" }]);
tick();
expect(component["generatedValue"]).toBe("second-username");
}));
it("should regenerate the username when regenerate$ emits", fakeAsync(() => {
mockLegacyUsernameGenerationService.generateUsername
.mockResolvedValueOnce("first-username")
.mockResolvedValueOnce("second-username");
component.ngOnChanges();
tick();
expect(component["generatedValue"]).toBe("first-username");
component["regenerate$"].next();
tick();
expect(component["generatedValue"]).toBe("second-username");
}));
it("should not show password type toggle when the generator type is username", () => {
fixture.detectChanges();
expect(fixture.nativeElement.querySelector("bit-toggle-group")).toBeNull();
});
});
});

View File

@@ -0,0 +1,159 @@
import { CommonModule } from "@angular/common";
import { Component, DestroyRef, EventEmitter, Input, OnChanges, Output } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { firstValueFrom, map, startWith, Subject, Subscription, switchMap, tap } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import {
CardComponent,
ColorPasswordModule,
IconButtonModule,
ItemModule,
SectionComponent,
SectionHeaderComponent,
ToggleGroupModule,
TypographyModule,
} from "@bitwarden/components";
import { GeneratorType } from "@bitwarden/generator-core";
import {
PasswordGenerationServiceAbstraction,
UsernameGenerationServiceAbstraction,
} from "@bitwarden/generator-legacy";
/**
* Renders a password or username generator UI and emits the most recently generated value.
* Used by the cipher form to be shown in a dialog/modal when generating cipher passwords/usernames.
*/
@Component({
selector: "vault-cipher-form-generator",
templateUrl: "./cipher-form-generator.component.html",
standalone: true,
imports: [
CommonModule,
CardComponent,
SectionComponent,
ToggleGroupModule,
JslibModule,
ItemModule,
ColorPasswordModule,
IconButtonModule,
SectionHeaderComponent,
TypographyModule,
],
})
export class CipherFormGeneratorComponent implements OnChanges {
/**
* The type of generator form to show.
*/
@Input({ required: true })
type: "password" | "username";
/**
* Emits an event when a new value is generated.
*/
@Output()
valueGenerated = new EventEmitter<string>();
protected get isPassword() {
return this.type === "password";
}
protected regenerateButtonTitle: string;
protected regenerate$ = new Subject<void>();
/**
* The currently generated value displayed to the user.
* @protected
*/
protected generatedValue: string = "";
/**
* The current password generation options.
* @private
*/
private passwordOptions$ = this.legacyPasswordGenerationService.getOptions$();
/**
* The current username generation options.
* @private
*/
private usernameOptions$ = this.legacyUsernameGenerationService.getOptions$();
/**
* The current password type specified by the password generation options.
* @protected
*/
protected passwordType$ = this.passwordOptions$.pipe(map(([options]) => options.type));
/**
* Tracks the regenerate$ subscription
* @private
*/
private subscription: Subscription | null;
constructor(
private i18nService: I18nService,
private legacyPasswordGenerationService: PasswordGenerationServiceAbstraction,
private legacyUsernameGenerationService: UsernameGenerationServiceAbstraction,
private destroyRef: DestroyRef,
) {}
ngOnChanges() {
this.regenerateButtonTitle = this.i18nService.t(
this.isPassword ? "regeneratePassword" : "regenerateUsername",
);
// If we have a previous subscription, clear it
if (this.subscription) {
this.subscription.unsubscribe();
this.subscription = null;
}
if (this.isPassword) {
this.setupPasswordGeneration();
} else {
this.setupUsernameGeneration();
}
}
private setupPasswordGeneration() {
this.subscription = this.regenerate$
.pipe(
startWith(null),
switchMap(() => this.passwordOptions$),
switchMap(([options]) => this.legacyPasswordGenerationService.generatePassword(options)),
tap(async (password) => {
await this.legacyPasswordGenerationService.addHistory(password);
}),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((password) => {
this.generatedValue = password;
this.valueGenerated.emit(password);
});
}
private setupUsernameGeneration() {
this.subscription = this.regenerate$
.pipe(
startWith(null),
switchMap(() => this.usernameOptions$),
switchMap((options) => this.legacyUsernameGenerationService.generateUsername(options)),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((username) => {
this.generatedValue = username;
this.valueGenerated.emit(username);
});
}
/**
* Switch the password generation type and save the options (generating a new password automatically).
* @param value The new password generation type.
*/
protected updatePasswordType = async (value: GeneratorType) => {
const [currentOptions] = await firstValueFrom(this.passwordOptions$);
currentOptions.type = value;
await this.legacyPasswordGenerationService.saveOptions(currentOptions);
};
}

View File

@@ -17,12 +17,8 @@
<bit-label>{{ "itemName" | i18n }}</bit-label>
<input bitInput formControlName="name" />
</bit-form-field>
<div class="tw-flex tw-flex-wrap tw-gap-1">
<bit-form-field
class="tw-flex-1"
*ngIf="showOwnership"
[disableMargin]="!showCollectionsControl"
>
<div class="tw-grid tw-grid-cols-2 tw-gap-1">
<bit-form-field *ngIf="showOwnership" [disableMargin]="!showCollectionsControl">
<bit-label>{{ "owner" | i18n }}</bit-label>
<bit-select formControlName="organizationId">
<bit-option
@@ -37,7 +33,10 @@
></bit-option>
</bit-select>
</bit-form-field>
<bit-form-field class="tw-flex-1" [disableMargin]="!showCollectionsControl">
<bit-form-field
[class.tw-col-span-2]="!showOwnership"
[disableMargin]="!showCollectionsControl"
>
<bit-label>{{ "folder" | i18n }}</bit-label>
<bit-select formControlName="folderId">
<bit-option

View File

@@ -23,6 +23,21 @@
<bit-form-field>
<bit-label>{{ "password" | i18n }}</bit-label>
<input bitInput formControlName="password" type="password" />
<bit-hint *ngIf="loginDetailsForm.controls.password.enabled">
<ng-container *ngIf="newPasswordGenerated">
{{ "securePasswordGenerated" | i18n }}
</ng-container>
<ng-container *ngIf="!newPasswordGenerated">
<span class="tw-sr-only">
{{ "useGeneratorHelpTextPartOne" | i18n }} {{ "useGeneratorHelpTextPartTwo" | i18n }}
</span>
<span aria-hidden="true">
{{ "useGeneratorHelpTextPartOne" | i18n }}
<i class="bwi bwi-generate" aria-hidden="true"></i>
{{ "useGeneratorHelpTextPartTwo" | i18n }}
</span>
</ng-container>
</bit-hint>
<button
type="button"
bitIconButton="bwi-check-circle"
@@ -78,11 +93,13 @@
<bit-label>
{{ "authenticatorKey" | i18n }}
<button
bitIconButton="bwi-question-circle"
bitLink
type="button"
size="small"
[bitPopoverTriggerFor]="totpPopover"
></button>
[appA11yTitle]="'learnMoreAboutAuthenticators' | i18n"
>
<i class="bwi bwi-sm bwi-question-circle" aria-hidden="true"></i>
</button>
<bit-popover #totpPopover [title]="'totpHelperTitle' | i18n">
<p>{{ (canCaptureTotp ? "totpHelperWithCapture" : "totpHelper") | i18n }}</p>
</bit-popover>

View File

@@ -125,14 +125,6 @@ describe("LoginDetailsSectionComponent", () => {
});
});
it("initializes 'loginDetailsForm' with generated password when creating a new cipher", async () => {
generationService.generateInitialPassword.mockResolvedValue("generated-password");
await component.ngOnInit();
expect(component.loginDetailsForm.controls.password.value).toBe("generated-password");
});
describe("viewHiddenFields", () => {
beforeEach(() => {
(cipherFormContainer.originalCipherView as CipherView) = {

View File

@@ -14,6 +14,7 @@ import {
CardComponent,
FormFieldModule,
IconButtonModule,
LinkModule,
PopoverModule,
SectionComponent,
SectionHeaderComponent,
@@ -43,6 +44,7 @@ import { AutofillOptionsComponent } from "../autofill-options/autofill-options.c
NgIf,
PopoverModule,
AutofillOptionsComponent,
LinkModule,
],
})
export class LoginDetailsSectionComponent implements OnInit {
@@ -52,6 +54,11 @@ export class LoginDetailsSectionComponent implements OnInit {
totp: [""],
});
/**
* Flag indicating whether a new password has been generated for the current form.
*/
newPasswordGenerated: boolean;
/**
* Whether the TOTP field can be captured from the current tab. Only available in the browser extension.
*/
@@ -148,7 +155,7 @@ export class LoginDetailsSectionComponent implements OnInit {
private async initNewCipher() {
this.loginDetailsForm.patchValue({
username: this.cipherFormContainer.config.initialValues?.username || "",
password: await this.generationService.generateInitialPassword(),
password: "",
});
}
@@ -193,6 +200,7 @@ export class LoginDetailsSectionComponent implements OnInit {
if (newPassword) {
this.loginDetailsForm.controls.password.patchValue(newPassword);
this.newPasswordGenerated = true;
}
};

View File

@@ -8,3 +8,4 @@ export {
export { TotpCaptureService } from "./abstractions/totp-capture.service";
export { CipherFormGenerationService } from "./abstractions/cipher-form-generation.service";
export { DefaultCipherFormConfigService } from "./services/default-cipher-form-config.service";
export { CipherFormGeneratorComponent } from "./components/cipher-generator/cipher-form-generator.component";

View File

@@ -23,8 +23,4 @@ export class DefaultCipherFormGenerationService implements CipherFormGenerationS
const options = await this.usernameGenerationService.getOptions();
return await this.usernameGenerationService.generateUsername(options);
}
async generateInitialPassword(): Promise<string> {
return await this.generatePassword();
}
}

View File

@@ -2,7 +2,7 @@
<bit-section-header>
<h2 bitTypography="h6">{{ "additionalOptions" | i18n }}</h2>
</bit-section-header>
<bit-card>
<bit-card class="[&_bit-form-field:last-of-type]:tw-mb-0">
<bit-form-field>
<bit-label>{{ "note" | i18n }}</bit-label>
<textarea readonly bitInput aria-readonly="true">{{ notes }}</textarea>

View File

@@ -0,0 +1,30 @@
<bit-section>
<bit-section-header>
<h2 bitTypography="h6">{{ "autofillOptions" | i18n }}</h2>
</bit-section-header>
<bit-card>
<ng-container *ngFor="let login of loginUris; let last = last">
<bit-form-field [disableMargin]="last" data-testid="autofill-view-list">
<bit-label>
{{ "website" | i18n }}
</bit-label>
<input readonly bitInput type="text" [value]="login.launchUri" aria-readonly="true" />
<button
bitIconButton="bwi-external-link"
bitSuffix
type="button"
(click)="openWebsite(login.launchUri)"
></button>
<button
bitIconButton="bwi-clone"
bitSuffix
type="button"
[appCopyClick]="login.launchUri"
[valueLabel]="'website' | i18n"
showToast
[appA11yTitle]="'copyValue' | i18n"
></button>
</bit-form-field>
</ng-container>
</bit-card>
</bit-section>

View File

@@ -0,0 +1,40 @@
import { CommonModule } from "@angular/common";
import { Component, Input } from "@angular/core";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view";
import {
CardComponent,
FormFieldModule,
SectionComponent,
SectionHeaderComponent,
TypographyModule,
IconButtonModule,
} from "@bitwarden/components";
import { TotpCaptureService } from "../../cipher-form";
@Component({
selector: "app-autofill-options-view",
templateUrl: "autofill-options-view.component.html",
standalone: true,
imports: [
CommonModule,
JslibModule,
CardComponent,
SectionComponent,
SectionHeaderComponent,
TypographyModule,
FormFieldModule,
IconButtonModule,
],
})
export class AutofillOptionsViewComponent {
@Input() loginUris: LoginUriView[];
constructor(private totpCaptureService: TotpCaptureService) {}
async openWebsite(selectedUri: string) {
await this.totpCaptureService.openAutofillNewTab(selectedUri);
}
}

View File

@@ -2,8 +2,8 @@
<bit-section-header>
<h2 bitTypography="h6">{{ setSectionTitle }}</h2>
</bit-section-header>
<bit-card>
<bit-form-field [disableMargin]="!card.number && !card.expiration && !card.code">
<bit-card class="[&_bit-form-field:last-of-type]:tw-mb-0">
<bit-form-field *ngIf="card.cardholderName">
<bit-label>{{ "cardholderName" | i18n }}</bit-label>
<input
readonly
@@ -14,7 +14,7 @@
data-testid="cardholder-name"
/>
</bit-form-field>
<bit-form-field *ngIf="card.number" [disableMargin]="!card.expiration && !card.code">
<bit-form-field *ngIf="card.number">
<bit-label>{{ "number" | i18n }}</bit-label>
<input
readonly
@@ -42,7 +42,7 @@
data-testid="copy-number"
></button>
</bit-form-field>
<bit-form-field *ngIf="card.expiration" [disableMargin]="!card.code">
<bit-form-field *ngIf="card.expiration">
<bit-label>{{ "expiration" | i18n }}</bit-label>
<input
readonly
@@ -53,7 +53,7 @@
data-testid="cardholder-expiration"
/>
</bit-form-field>
<bit-form-field *ngIf="card.code" disableMargin>
<bit-form-field *ngIf="card.code">
<bit-label>{{ "securityCode" | i18n }}</bit-label>
<input
readonly

View File

@@ -13,8 +13,6 @@ import {
IconButtonModule,
} from "@bitwarden/components";
import { OrgIconDirective } from "../../components/org-icon.directive";
@Component({
selector: "app-card-details-view",
templateUrl: "card-details-view.component.html",
@@ -26,7 +24,6 @@ import { OrgIconDirective } from "../../components/org-icon.directive";
SectionComponent,
SectionHeaderComponent,
TypographyModule,
OrgIconDirective,
FormFieldModule,
IconButtonModule,
],

View File

@@ -8,10 +8,19 @@
>
</app-item-details-v2>
<!-- LOGIN CREDENTIALS -->
<app-login-credentials-view
*ngIf="hasLogin"
[login]="cipher.login"
[viewPassword]="cipher.viewPassword"
></app-login-credentials-view>
<!-- AUTOFILL OPTIONS -->
<app-autofill-options-view *ngIf="hasAutofill" [loginUris]="cipher.login.uris">
</app-autofill-options-view>
<!-- CARD DETAILS -->
<ng-container *ngIf="hasCard">
<app-card-details-view [card]="cipher.card"></app-card-details-view>
</ng-container>
<app-card-details-view *ngIf="hasCard" [card]="cipher.card"></app-card-details-view>
<!-- IDENTITY SECTIONS -->
<app-view-identity-sections *ngIf="cipher.identity" [cipher]="cipher">

View File

@@ -13,16 +13,14 @@ import { CollectionView } from "@bitwarden/common/vault/models/view/collection.v
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import { SearchModule } from "@bitwarden/components";
import { PopupFooterComponent } from "../../../../apps/browser/src/platform/popup/layout/popup-footer.component";
import { PopupHeaderComponent } from "../../../../apps/browser/src/platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "../../../../apps/browser/src/platform/popup/layout/popup-page.component";
import { AdditionalOptionsComponent } from "./additional-options/additional-options.component";
import { AttachmentsV2ViewComponent } from "./attachments/attachments-v2-view.component";
import { AutofillOptionsViewComponent } from "./autofill-options/autofill-options-view.component";
import { CardDetailsComponent } from "./card-details/card-details-view.component";
import { CustomFieldV2Component } from "./custom-fields/custom-fields-v2.component";
import { ItemDetailsV2Component } from "./item-details/item-details-v2.component";
import { ItemHistoryV2Component } from "./item-history/item-history-v2.component";
import { LoginCredentialsViewComponent } from "./login-credentials/login-credentials-view.component";
import { ViewIdentitySectionsComponent } from "./view-identity-sections/view-identity-sections.component";
@Component({
@@ -33,9 +31,6 @@ import { ViewIdentitySectionsComponent } from "./view-identity-sections/view-ide
CommonModule,
SearchModule,
JslibModule,
PopupPageComponent,
PopupHeaderComponent,
PopupFooterComponent,
ItemDetailsV2Component,
AdditionalOptionsComponent,
AttachmentsV2ViewComponent,
@@ -43,6 +38,8 @@ import { ViewIdentitySectionsComponent } from "./view-identity-sections/view-ide
CustomFieldV2Component,
CardDetailsComponent,
ViewIdentitySectionsComponent,
LoginCredentialsViewComponent,
AutofillOptionsViewComponent,
],
})
export class CipherViewComponent implements OnInit, OnDestroy {
@@ -61,6 +58,7 @@ export class CipherViewComponent implements OnInit, OnDestroy {
async ngOnInit() {
await this.loadCipherData();
}
ngOnDestroy(): void {
this.destroyed$.next();
this.destroyed$.complete();
@@ -71,6 +69,15 @@ export class CipherViewComponent implements OnInit, OnDestroy {
return cardholderName || code || expMonth || expYear || brand || number;
}
get hasLogin() {
const { username, password, totp } = this.cipher.login;
return username || password || totp;
}
get hasAutofill() {
return this.cipher.login?.uris.length > 0;
}
async loadCipherData() {
if (this.cipher.collectionIds.length > 0) {
this.collections$ = this.collectionService

View File

@@ -22,6 +22,7 @@
<ul
[attr.aria-label]="'itemLocation' | i18n"
*ngIf="cipher.collectionIds?.length || cipher.organizationId || cipher.folderId"
class="tw-mb-0"
>
<li
*ngIf="cipher.organizationId && organization"

View File

@@ -7,7 +7,13 @@
<span class="tw-font-bold">{{ "lastEdited" | i18n }}:</span>
{{ cipher.revisionDate | date: "medium" }}
</p>
<p class="tw-mb-1 tw-text-xs tw-text-muted tw-select-none">
<p
class="tw-text-xs tw-text-muted tw-select-none"
[ngClass]="{
'tw-mb-1 ': cipher.hasPasswordHistory && isLogin,
'tw-mb-0': !cipher.hasPasswordHistory || !isLogin,
}"
>
<span class="tw-font-bold">{{ "dateCreated" | i18n }}:</span>
{{ cipher.creationDate | date: "medium" }}
</p>
@@ -20,7 +26,7 @@
{{ cipher.passwordRevisionDisplayDate | date: "medium" }}
</p>
<a
*ngIf="cipher.hasPasswordHistory"
*ngIf="cipher.hasPasswordHistory && isLogin"
class="tw-font-bold tw-no-underline"
routerLink="/cipher-password-history"
[queryParams]="{ cipherId: cipher.id }"

View File

@@ -0,0 +1,106 @@
<bit-section>
<bit-section-header>
<h2 bitTypography="h6">{{ "loginCredentials" | i18n }}</h2>
</bit-section-header>
<bit-card>
<bit-form-field [disableMargin]="!login.password && !login.totp">
<bit-label>
{{ "username" | i18n }}
</bit-label>
<input
readonly
bitInput
type="text"
[value]="login.username"
aria-readonly="true"
data-testid="login-username"
/>
<button
bitIconButton="bwi-clone"
bitSuffix
type="button"
[appCopyClick]="login.username"
[valueLabel]="'username' | i18n"
showToast
[appA11yTitle]="'copyValue' | i18n"
data-testid="toggle-username"
></button>
</bit-form-field>
<bit-form-field [disableMargin]="!login.totp">
<bit-label>{{ "password" | i18n }}</bit-label>
<input
readonly
bitInput
type="password"
[value]="login.password"
aria-readonly="true"
data-testid="login-password"
/>
<button
bitSuffix
type="button"
bitIconButton
bitPasswordInputToggle
data-testid="toggle-password"
(toggledChange)="pwToggleValue($event)"
></button>
<button
*ngIf="viewPassword && passwordRevealed"
bitIconButton="bwi-numbered-list"
bitSuffix
type="button"
data-testid="toggle-password-count"
[appA11yTitle]="'toggleCharacterCount' | i18n"
appStopClick
(click)="togglePasswordCount()"
></button>
<button
bitIconButton="bwi-clone"
bitSuffix
type="button"
[appCopyClick]="login.password"
[valueLabel]="'password' | i18n"
showToast
[appA11yTitle]="'copyValue' | i18n"
data-testid="copy-password"
></button>
</bit-form-field>
<ng-container *ngIf="showPasswordCount && passwordRevealed">
<bit-color-password [password]="login.password" [showCount]="true"></bit-color-password>
</ng-container>
<bit-form-field disableMargin *ngIf="login.totp">
<bit-label
>{{ "verificationCodeTotp" | i18n }}
<span
*ngIf="!(isPremium$ | async)"
bitBadge
variant="success"
class="tw-ml-2"
(click)="getPremium()"
>
{{ "premium" | i18n }}
</span>
</bit-label>
<input
readonly
bitInput
type="text"
[value]="login.totp"
aria-readonly="true"
data-testid="login-totp"
[disabled]="!(isPremium$ | async)"
/>
<button
bitIconButton="bwi-clone"
bitSuffix
type="button"
[appCopyClick]="login.totp"
[valueLabel]="'verificationCodeTotp' | i18n"
showToast
[appA11yTitle]="'copyValue' | i18n"
data-testid="copy-totp"
[disabled]="!(isPremium$ | async)"
></button>
</bit-form-field>
</bit-card>
</bit-section>

View File

@@ -0,0 +1,63 @@
import { CommonModule } from "@angular/common";
import { Component, Input } from "@angular/core";
import { Router } from "@angular/router";
import { Observable, shareReplay } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { LoginView } from "@bitwarden/common/vault/models/view/login.view";
import {
CardComponent,
FormFieldModule,
SectionComponent,
SectionHeaderComponent,
TypographyModule,
IconButtonModule,
BadgeModule,
ColorPasswordModule,
} from "@bitwarden/components";
@Component({
selector: "app-login-credentials-view",
templateUrl: "login-credentials-view.component.html",
standalone: true,
imports: [
CommonModule,
JslibModule,
CardComponent,
SectionComponent,
SectionHeaderComponent,
TypographyModule,
FormFieldModule,
IconButtonModule,
BadgeModule,
ColorPasswordModule,
],
})
export class LoginCredentialsViewComponent {
@Input() login: LoginView;
@Input() viewPassword: boolean;
isPremium$: Observable<boolean> =
this.billingAccountProfileStateService.hasPremiumFromAnySource$.pipe(
shareReplay({ refCount: true, bufferSize: 1 }),
);
showPasswordCount: boolean = false;
passwordRevealed: boolean = false;
constructor(
private billingAccountProfileStateService: BillingAccountProfileStateService,
private router: Router,
) {}
async getPremium() {
await this.router.navigate(["/premium"]);
}
pwToggleValue(evt: boolean) {
this.passwordRevealed = evt;
}
togglePasswordCount() {
this.showPasswordCount = !this.showPasswordCount;
}
}

View File

@@ -175,7 +175,6 @@ export class AssignCollectionsComponent implements OnInit, OnDestroy, AfterViewI
) {}
async ngOnInit() {
const v1FCEnabled = await this.configService.getFeatureFlag(FeatureFlag.FlexibleCollectionsV1);
const restrictProviderAccess = await this.configService.getFeatureFlag(
FeatureFlag.RestrictProviderAccess,
);
@@ -186,7 +185,7 @@ export class AssignCollectionsComponent implements OnInit, OnDestroy, AfterViewI
this.showOrgSelector = true;
}
await this.initializeItems(this.selectedOrgId, v1FCEnabled, restrictProviderAccess);
await this.initializeItems(this.selectedOrgId, restrictProviderAccess);
if (this.selectedOrgId && this.selectedOrgId !== MY_VAULT_ID) {
await this.handleOrganizationCiphers();
@@ -332,11 +331,7 @@ export class AssignCollectionsComponent implements OnInit, OnDestroy, AfterViewI
}
}
private async initializeItems(
organizationId: OrganizationId,
v1FCEnabled: boolean,
restrictProviderAccess: boolean,
) {
private async initializeItems(organizationId: OrganizationId, restrictProviderAccess: boolean) {
this.totalItemCount = this.params.ciphers.length;
// If organizationId is not present or organizationId is MyVault, then all ciphers are considered personal items
@@ -351,7 +346,7 @@ export class AssignCollectionsComponent implements OnInit, OnDestroy, AfterViewI
const org = await this.organizationService.get(organizationId);
this.orgName = org.name;
this.editableItems = org.canEditAllCiphers(v1FCEnabled, restrictProviderAccess)
this.editableItems = org.canEditAllCiphers(restrictProviderAccess)
? this.params.ciphers
: this.params.ciphers.filter((c) => c.edit);

View File

@@ -1,4 +1,4 @@
import { svgIcon } from "../icon";
import { svgIcon } from "@bitwarden/components";
export const DeactivatedOrg = svgIcon`
<svg width="138" height="118" viewBox="0 0 138 118" fill="none" xmlns="http://www.w3.org/2000/svg">

View File

@@ -0,0 +1,3 @@
export * from "./deactivated-org";
export * from "./no-folders";
export * from "./vault";

View File

@@ -0,0 +1,19 @@
import { svgIcon } from "@bitwarden/components";
export const NoFolders = svgIcon`
<svg width="147" height="91" viewBox="0 0 147 91" fill="none" xmlns="http://www.w3.org/2000/svg">
<path class="tw-stroke-info-600" d="M64.8263 1.09473V9.90589" stroke-width="1.25" stroke-linecap="round"/>
<path class="tw-stroke-info-600" d="M42.1936 6.09082L46.6564 13.7215" stroke-width="1.25" stroke-linecap="round"/>
<path class="tw-stroke-info-600" d="M53.8507 2.4209L55.4006 11.0982" stroke-width="1.25" stroke-linecap="round"/>
<path class="tw-stroke-info-600" d="M76.1821 2.50293L73.8719 11.0139" stroke-width="1.25" stroke-linecap="round"/>
<path class="tw-stroke-info-600" d="M87.4594 6.09082L82.9966 13.7215" stroke-width="1.25" stroke-linecap="round"/>
<path class="tw-stroke-text-headers" d="M100.358 89.3406H28.3419C26.1377 89.3406 24.3422 87.8759 24.3422 86.0715V24.5211C24.3422 22.7167 26.127 21.252 28.3419 21.252H61.6082C63.8124 21.252 65.608 22.7167 65.608 24.5211V28.5013C65.608 30.5073 67.3928 32.1313 69.6077 32.1313H82.105" stroke-width="2.5" stroke-miterlimit="10"/>
<path class="tw-stroke-text-headers" d="M82.344 41.7686H40.1042C37.9646 41.7686 36.1475 42.7663 35.8465 44.1036L26.3203 86.2411C25.9547 87.8757 27.9653 89.3404 30.5781 89.3404H107.906C110.045 89.3404 111.862 88.3427 112.163 87.0053L116.904 62.5844" stroke-width="2.5" stroke-miterlimit="10"/>
<path class="tw-stroke-info-600" d="M28.1209 36.6897V27.0451C28.1209 25.9523 29.0068 25.0664 30.0996 25.0664H40.7775" stroke-width="1.25" stroke-linecap="round"/>
<path class="tw-stroke-text-headers" d="M105.433 64.0751C119.875 65.725 132.919 55.3558 134.569 40.9147C136.219 26.4737 125.849 13.4294 111.408 11.7794C96.9673 10.1295 83.923 20.4988 82.2731 34.9398C80.6232 49.3809 90.9924 62.4252 105.433 64.0751Z" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
<path class="tw-stroke-info-600" d="M105.834 60.5706C118.253 61.9895 129.484 52.9549 130.92 40.3912M85.9456 35.2528C87.381 22.6891 98.6125 13.6544 111.032 15.0734" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
<path class="tw-stroke-text-headers" d="M122.014 60.6246L125.553 65.0773L138.676 81.5851C139.806 83.0073 141.876 83.2437 143.298 82.1132L143.558 81.906C144.98 80.7755 145.217 78.7062 144.086 77.2841L130.964 60.7762L127.424 56.3235" stroke-width="2.19854" stroke-linecap="round" stroke-linejoin="round"/>
<path class="tw-stroke-text-headers" d="M101.309 31.3349C101.309 31.3349 101.415 28.8033 103.894 26.6553C105.382 25.3511 107.153 25.0059 108.747 24.9675C110.199 24.9291 111.51 25.1976 112.254 25.6196C113.6 26.31 116.186 27.9594 116.186 31.5267C116.186 35.2858 113.919 36.9735 111.368 38.8531C108.818 40.7326 109.185 43.1796 109.185 45.2509" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
<path class="tw-fill-text-headers" d="M109.191 51.7764C110.02 51.7764 110.691 51.1049 110.691 50.2764C110.691 49.448 110.02 48.7764 109.191 48.7764C108.363 48.7764 107.691 49.448 107.691 50.2764C107.691 51.1049 108.363 51.7764 109.191 51.7764Z" />
</svg>
`;

View File

@@ -1,4 +1,4 @@
import { svgIcon } from "../icon";
import { svgIcon } from "@bitwarden/components";
export const Vault = svgIcon`
<svg fill="none" width="100" height="90" viewBox="0 0 100 90" xmlns="http://www.w3.org/2000/svg">

Some files were not shown because too many files have changed in this diff Show More