mirror of
https://github.com/bitwarden/browser
synced 2026-02-25 17:13:24 +00:00
Merge remote-tracking branch 'origin/sdk-encrypt-service' into km/cose
This commit is contained in:
@@ -45,8 +45,6 @@ import {
|
||||
DefaultAuthRequestApiService,
|
||||
DefaultLoginSuccessHandlerService,
|
||||
LoginSuccessHandlerService,
|
||||
PasswordLoginStrategy,
|
||||
PasswordLoginStrategyData,
|
||||
LoginApprovalComponentServiceAbstraction,
|
||||
} from "@bitwarden/auth/common";
|
||||
import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstractions/api.service";
|
||||
@@ -1460,37 +1458,6 @@ const safeProviders: SafeProvider[] = [
|
||||
useClass: DefaultLoginSuccessHandlerService,
|
||||
deps: [SyncService, UserAsymmetricKeysRegenerationService],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: PasswordLoginStrategy,
|
||||
useClass: PasswordLoginStrategy,
|
||||
deps: [
|
||||
PasswordLoginStrategyData,
|
||||
PasswordStrengthServiceAbstraction,
|
||||
PolicyServiceAbstraction,
|
||||
LoginStrategyServiceAbstraction,
|
||||
AccountServiceAbstraction,
|
||||
InternalMasterPasswordServiceAbstraction,
|
||||
KeyService,
|
||||
EncryptService,
|
||||
ApiServiceAbstraction,
|
||||
TokenServiceAbstraction,
|
||||
AppIdServiceAbstraction,
|
||||
PlatformUtilsServiceAbstraction,
|
||||
MessagingServiceAbstraction,
|
||||
LogService,
|
||||
StateServiceAbstraction,
|
||||
TwoFactorServiceAbstraction,
|
||||
InternalUserDecryptionOptionsServiceAbstraction,
|
||||
BillingAccountProfileStateService,
|
||||
VaultTimeoutSettingsService,
|
||||
KdfConfigService,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: PasswordLoginStrategyData,
|
||||
useClass: PasswordLoginStrategyData,
|
||||
deps: [],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: TaskService,
|
||||
useClass: DefaultTaskService,
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Directive, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { BehaviorSubject, Subject, firstValueFrom, from, map, switchMap, takeUntil } from "rxjs";
|
||||
import { BehaviorSubject, Subject, firstValueFrom, from, switchMap, takeUntil } from "rxjs";
|
||||
|
||||
import { SearchService } from "@bitwarden/common/abstractions/search.service";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
@@ -22,11 +21,9 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
|
||||
|
||||
loaded = false;
|
||||
ciphers: CipherView[] = [];
|
||||
searchPlaceholder: string = null;
|
||||
filter: (cipher: CipherView) => boolean = null;
|
||||
deleted = false;
|
||||
organization: Organization;
|
||||
accessEvents = false;
|
||||
|
||||
protected searchPending = false;
|
||||
|
||||
@@ -45,20 +42,7 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
|
||||
protected searchService: SearchService,
|
||||
protected cipherService: CipherService,
|
||||
protected accountService: AccountService,
|
||||
) {
|
||||
this.accountService.activeAccount$
|
||||
.pipe(
|
||||
getUserId,
|
||||
switchMap((userId) =>
|
||||
this.cipherService.cipherViews$(userId).pipe(map((ciphers) => ({ userId, ciphers }))),
|
||||
),
|
||||
takeUntilDestroyed(),
|
||||
)
|
||||
.subscribe(({ userId, ciphers }) => {
|
||||
void this.doSearch(ciphers, userId);
|
||||
this.loaded = true;
|
||||
});
|
||||
}
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this._searchText$
|
||||
|
||||
@@ -161,8 +161,9 @@ export class LoginComponent implements OnInit, OnDestroy {
|
||||
tap(async (flag) => {
|
||||
// If the flag is turned OFF, we must force a reload to ensure the correct UI is shown
|
||||
if (!flag) {
|
||||
const qParams = await firstValueFrom(this.activatedRoute.queryParams);
|
||||
const uniqueQueryParams = {
|
||||
...this.activatedRoute.queryParams,
|
||||
...qParams,
|
||||
// adding a unique timestamp to the query params to force a reload
|
||||
t: new Date().getTime().toString(), // Adding a unique timestamp as a query parameter
|
||||
};
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
|
||||
import { LoginEmailServiceAbstraction } from "../../common/abstractions/login-email.service";
|
||||
import { LoginStrategyServiceAbstraction } from "../../common/abstractions/login-strategy.service";
|
||||
import { PasswordLoginStrategy } from "../../common/login-strategies/password-login.strategy";
|
||||
|
||||
/**
|
||||
* Component for verifying a new device via a one-time password (OTP).
|
||||
@@ -58,7 +57,6 @@ export class NewDeviceVerificationComponent implements OnInit, OnDestroy {
|
||||
constructor(
|
||||
private router: Router,
|
||||
private formBuilder: FormBuilder,
|
||||
private passwordLoginStrategy: PasswordLoginStrategy,
|
||||
private apiService: ApiService,
|
||||
private loginStrategyService: LoginStrategyServiceAbstraction,
|
||||
private logService: LogService,
|
||||
|
||||
@@ -427,7 +427,6 @@ export class SsoComponent implements OnInit {
|
||||
);
|
||||
this.formPromise = this.loginStrategyService.logIn(credentials);
|
||||
const authResult = await this.formPromise;
|
||||
|
||||
if (authResult.requiresTwoFactor) {
|
||||
return await this.handleTwoFactorRequired(orgSsoIdentifier);
|
||||
}
|
||||
@@ -441,9 +440,10 @@ export class SsoComponent implements OnInit {
|
||||
// - Browser SSO on extension open
|
||||
// Note: you cannot set this in state before 2FA b/c there won't be an account in state.
|
||||
|
||||
// Grabbing the active user id right before making the state set to ensure it exists.
|
||||
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
|
||||
await this.ssoLoginService.setActiveUserOrganizationSsoIdentifier(orgSsoIdentifier, userId);
|
||||
await this.ssoLoginService.setActiveUserOrganizationSsoIdentifier(
|
||||
orgSsoIdentifier,
|
||||
authResult.userId,
|
||||
);
|
||||
|
||||
// must come after 2fa check since user decryption options aren't available if 2fa is required
|
||||
const userDecryptionOpts = await firstValueFrom(
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
VaultTimeoutSettingsService,
|
||||
} from "@bitwarden/common/key-management/vault-timeout";
|
||||
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
@@ -53,6 +54,7 @@ describe("AuthRequestLoginStrategy", () => {
|
||||
let billingAccountProfileStateService: MockProxy<BillingAccountProfileStateService>;
|
||||
let vaultTimeoutSettingsService: MockProxy<VaultTimeoutSettingsService>;
|
||||
let kdfConfigService: MockProxy<KdfConfigService>;
|
||||
let environmentService: MockProxy<EnvironmentService>;
|
||||
|
||||
const mockUserId = Utils.newGuid() as UserId;
|
||||
let accountService: FakeAccountService;
|
||||
@@ -88,6 +90,7 @@ describe("AuthRequestLoginStrategy", () => {
|
||||
billingAccountProfileStateService = mock<BillingAccountProfileStateService>();
|
||||
vaultTimeoutSettingsService = mock<VaultTimeoutSettingsService>();
|
||||
kdfConfigService = mock<KdfConfigService>();
|
||||
environmentService = mock<EnvironmentService>();
|
||||
|
||||
accountService = mockAccountServiceWith(mockUserId);
|
||||
masterPasswordService = new FakeMasterPasswordService();
|
||||
@@ -117,6 +120,7 @@ describe("AuthRequestLoginStrategy", () => {
|
||||
billingAccountProfileStateService,
|
||||
vaultTimeoutSettingsService,
|
||||
kdfConfigService,
|
||||
environmentService,
|
||||
);
|
||||
|
||||
tokenResponse = identityTokenResponseFactory();
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
VaultTimeoutSettingsService,
|
||||
} from "@bitwarden/common/key-management/vault-timeout";
|
||||
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
@@ -123,6 +124,7 @@ describe("LoginStrategy", () => {
|
||||
let billingAccountProfileStateService: MockProxy<BillingAccountProfileStateService>;
|
||||
let vaultTimeoutSettingsService: MockProxy<VaultTimeoutSettingsService>;
|
||||
let kdfConfigService: MockProxy<KdfConfigService>;
|
||||
let environmentService: MockProxy<EnvironmentService>;
|
||||
|
||||
let passwordLoginStrategy: PasswordLoginStrategy;
|
||||
let credentials: PasswordLoginCredentials;
|
||||
@@ -147,6 +149,7 @@ describe("LoginStrategy", () => {
|
||||
policyService = mock<PolicyService>();
|
||||
passwordStrengthService = mock<PasswordStrengthService>();
|
||||
billingAccountProfileStateService = mock<BillingAccountProfileStateService>();
|
||||
environmentService = mock<EnvironmentService>();
|
||||
|
||||
vaultTimeoutSettingsService = mock<VaultTimeoutSettingsService>();
|
||||
|
||||
@@ -175,6 +178,7 @@ describe("LoginStrategy", () => {
|
||||
billingAccountProfileStateService,
|
||||
vaultTimeoutSettingsService,
|
||||
kdfConfigService,
|
||||
environmentService,
|
||||
);
|
||||
credentials = new PasswordLoginCredentials(email, masterPassword);
|
||||
});
|
||||
@@ -496,6 +500,7 @@ describe("LoginStrategy", () => {
|
||||
billingAccountProfileStateService,
|
||||
vaultTimeoutSettingsService,
|
||||
kdfConfigService,
|
||||
environmentService,
|
||||
);
|
||||
|
||||
apiService.postIdentityToken.mockResolvedValue(identityTokenResponseFactory());
|
||||
@@ -559,6 +564,7 @@ describe("LoginStrategy", () => {
|
||||
billingAccountProfileStateService,
|
||||
vaultTimeoutSettingsService,
|
||||
kdfConfigService,
|
||||
environmentService,
|
||||
);
|
||||
|
||||
const result = await passwordLoginStrategy.logIn(credentials);
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from "@bitwarden/common/key-management/vault-timeout";
|
||||
import { KeysRequest } from "@bitwarden/common/models/request/keys.request";
|
||||
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
@@ -93,6 +94,7 @@ export abstract class LoginStrategy {
|
||||
protected billingAccountProfileStateService: BillingAccountProfileStateService,
|
||||
protected vaultTimeoutSettingsService: VaultTimeoutSettingsService,
|
||||
protected KdfConfigService: KdfConfigService,
|
||||
protected environmentService: EnvironmentService,
|
||||
) {}
|
||||
|
||||
abstract exportCache(): CacheData;
|
||||
@@ -196,6 +198,10 @@ export abstract class LoginStrategy {
|
||||
emailVerified: accountInformation.email_verified ?? false,
|
||||
});
|
||||
|
||||
// User env must be seeded from currently set env before switching to the account
|
||||
// to avoid any incorrect emissions of the global default env.
|
||||
await this.environmentService.seedUserEnvironment(userId);
|
||||
|
||||
await this.accountService.switchAccount(userId);
|
||||
|
||||
await this.stateService.addAccount(
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
VaultTimeoutSettingsService,
|
||||
} from "@bitwarden/common/key-management/vault-timeout";
|
||||
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
@@ -80,6 +81,7 @@ describe("PasswordLoginStrategy", () => {
|
||||
let billingAccountProfileStateService: MockProxy<BillingAccountProfileStateService>;
|
||||
let vaultTimeoutSettingsService: MockProxy<VaultTimeoutSettingsService>;
|
||||
let kdfConfigService: MockProxy<KdfConfigService>;
|
||||
let environmentService: MockProxy<EnvironmentService>;
|
||||
|
||||
let passwordLoginStrategy: PasswordLoginStrategy;
|
||||
let credentials: PasswordLoginCredentials;
|
||||
@@ -106,6 +108,7 @@ describe("PasswordLoginStrategy", () => {
|
||||
billingAccountProfileStateService = mock<BillingAccountProfileStateService>();
|
||||
vaultTimeoutSettingsService = mock<VaultTimeoutSettingsService>();
|
||||
kdfConfigService = mock<KdfConfigService>();
|
||||
environmentService = mock<EnvironmentService>();
|
||||
|
||||
appIdService.getAppId.mockResolvedValue(deviceId);
|
||||
tokenService.decodeAccessToken.mockResolvedValue({
|
||||
@@ -144,6 +147,7 @@ describe("PasswordLoginStrategy", () => {
|
||||
billingAccountProfileStateService,
|
||||
vaultTimeoutSettingsService,
|
||||
kdfConfigService,
|
||||
environmentService,
|
||||
);
|
||||
credentials = new PasswordLoginCredentials(email, masterPassword);
|
||||
tokenResponse = identityTokenResponseFactory(masterPasswordPolicy);
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from "@bitwarden/common/key-management/vault-timeout";
|
||||
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
|
||||
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
@@ -63,6 +64,7 @@ describe("SsoLoginStrategy", () => {
|
||||
let billingAccountProfileStateService: MockProxy<BillingAccountProfileStateService>;
|
||||
let vaultTimeoutSettingsService: MockProxy<VaultTimeoutSettingsService>;
|
||||
let kdfConfigService: MockProxy<KdfConfigService>;
|
||||
let environmentService: MockProxy<EnvironmentService>;
|
||||
|
||||
let ssoLoginStrategy: SsoLoginStrategy;
|
||||
let credentials: SsoLoginCredentials;
|
||||
@@ -98,6 +100,7 @@ describe("SsoLoginStrategy", () => {
|
||||
billingAccountProfileStateService = mock<BillingAccountProfileStateService>();
|
||||
vaultTimeoutSettingsService = mock<VaultTimeoutSettingsService>();
|
||||
kdfConfigService = mock<KdfConfigService>();
|
||||
environmentService = mock<EnvironmentService>();
|
||||
|
||||
tokenService.getTwoFactorToken.mockResolvedValue(null);
|
||||
appIdService.getAppId.mockResolvedValue(deviceId);
|
||||
@@ -142,6 +145,7 @@ describe("SsoLoginStrategy", () => {
|
||||
billingAccountProfileStateService,
|
||||
vaultTimeoutSettingsService,
|
||||
kdfConfigService,
|
||||
environmentService,
|
||||
);
|
||||
credentials = new SsoLoginCredentials(ssoCode, ssoCodeVerifier, ssoRedirectUrl, ssoOrgId);
|
||||
});
|
||||
|
||||
@@ -97,7 +97,6 @@ describe("UserApiLoginStrategy", () => {
|
||||
|
||||
apiLogInStrategy = new UserApiLoginStrategy(
|
||||
cache,
|
||||
environmentService,
|
||||
keyConnectorService,
|
||||
accountService,
|
||||
masterPasswordService,
|
||||
@@ -115,6 +114,7 @@ describe("UserApiLoginStrategy", () => {
|
||||
billingAccountProfileStateService,
|
||||
vaultTimeoutSettingsService,
|
||||
kdfConfigService,
|
||||
environmentService,
|
||||
);
|
||||
|
||||
credentials = new UserApiLoginCredentials(apiClientId, apiClientSecret);
|
||||
|
||||
@@ -7,7 +7,6 @@ import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-con
|
||||
import { UserApiTokenRequest } from "@bitwarden/common/auth/models/request/identity-token/user-api-token.request";
|
||||
import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response";
|
||||
import { VaultTimeoutAction } from "@bitwarden/common/key-management/vault-timeout";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { UserApiLoginCredentials } from "../models/domain/login-credentials";
|
||||
@@ -31,7 +30,6 @@ export class UserApiLoginStrategy extends LoginStrategy {
|
||||
|
||||
constructor(
|
||||
data: UserApiLoginStrategyData,
|
||||
private environmentService: EnvironmentService,
|
||||
private keyConnectorService: KeyConnectorService,
|
||||
...sharedDeps: ConstructorParameters<typeof LoginStrategy>
|
||||
) {
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
VaultTimeoutSettingsService,
|
||||
} from "@bitwarden/common/key-management/vault-timeout";
|
||||
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
@@ -52,6 +53,7 @@ describe("WebAuthnLoginStrategy", () => {
|
||||
let billingAccountProfileStateService: MockProxy<BillingAccountProfileStateService>;
|
||||
let vaultTimeoutSettingsService: MockProxy<VaultTimeoutSettingsService>;
|
||||
let kdfConfigService: MockProxy<KdfConfigService>;
|
||||
let environmentService: MockProxy<EnvironmentService>;
|
||||
|
||||
let webAuthnLoginStrategy!: WebAuthnLoginStrategy;
|
||||
|
||||
@@ -95,6 +97,7 @@ describe("WebAuthnLoginStrategy", () => {
|
||||
billingAccountProfileStateService = mock<BillingAccountProfileStateService>();
|
||||
vaultTimeoutSettingsService = mock<VaultTimeoutSettingsService>();
|
||||
kdfConfigService = mock<KdfConfigService>();
|
||||
environmentService = mock<EnvironmentService>();
|
||||
|
||||
tokenService.getTwoFactorToken.mockResolvedValue(null);
|
||||
appIdService.getAppId.mockResolvedValue(deviceId);
|
||||
@@ -120,6 +123,7 @@ describe("WebAuthnLoginStrategy", () => {
|
||||
billingAccountProfileStateService,
|
||||
vaultTimeoutSettingsService,
|
||||
kdfConfigService,
|
||||
environmentService,
|
||||
);
|
||||
|
||||
// Create credentials
|
||||
|
||||
@@ -402,6 +402,7 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
|
||||
this.billingAccountProfileStateService,
|
||||
this.vaultTimeoutSettingsService,
|
||||
this.kdfConfigService,
|
||||
this.environmentService,
|
||||
];
|
||||
|
||||
return source.pipe(
|
||||
@@ -430,7 +431,6 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
|
||||
case AuthenticationType.UserApiKey:
|
||||
return new UserApiLoginStrategy(
|
||||
data?.userApiKey ?? new UserApiLoginStrategyData(),
|
||||
this.environmentService,
|
||||
this.keyConnectorService,
|
||||
...sharedDeps,
|
||||
);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ServerConfig } from "../platform/abstractions/config/server-config";
|
||||
|
||||
/**
|
||||
* Feature flags.
|
||||
*
|
||||
@@ -5,7 +7,6 @@
|
||||
*/
|
||||
export enum FeatureFlag {
|
||||
/* Admin Console Team */
|
||||
ProviderClientVaultPrivacyBanner = "ac-2833-provider-client-vault-privacy-banner",
|
||||
AccountDeprovisioning = "pm-10308-account-deprovisioning",
|
||||
VerifiedSsoDomainEndpoint = "pm-12337-refactor-sso-details-endpoint",
|
||||
LimitItemDeletion = "pm-15493-restrict-item-deletion-to-can-manage-permission",
|
||||
@@ -23,6 +24,9 @@ export enum FeatureFlag {
|
||||
NotificationRefresh = "notification-refresh",
|
||||
UseTreeWalkerApiForPageDetailsCollection = "use-tree-walker-api-for-page-details-collection",
|
||||
|
||||
/* Key Management */
|
||||
UseSDKForDecryption = "use-sdk-for-decryption",
|
||||
|
||||
/* Tools */
|
||||
ItemShare = "item-share",
|
||||
CriticalApps = "pm-14466-risk-insights-critical-application",
|
||||
@@ -64,7 +68,6 @@ const FALSE = false as boolean;
|
||||
*/
|
||||
export const DefaultFeatureFlagValue = {
|
||||
/* Admin Console Team */
|
||||
[FeatureFlag.ProviderClientVaultPrivacyBanner]: FALSE,
|
||||
[FeatureFlag.AccountDeprovisioning]: FALSE,
|
||||
[FeatureFlag.VerifiedSsoDomainEndpoint]: FALSE,
|
||||
[FeatureFlag.LimitItemDeletion]: FALSE,
|
||||
@@ -82,6 +85,9 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.NotificationRefresh]: FALSE,
|
||||
[FeatureFlag.UseTreeWalkerApiForPageDetailsCollection]: FALSE,
|
||||
|
||||
/* Key Management */
|
||||
[FeatureFlag.UseSDKForDecryption]: FALSE,
|
||||
|
||||
/* Tools */
|
||||
[FeatureFlag.ItemShare]: FALSE,
|
||||
[FeatureFlag.CriticalApps]: FALSE,
|
||||
@@ -113,3 +119,14 @@ export const DefaultFeatureFlagValue = {
|
||||
export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;
|
||||
|
||||
export type FeatureFlagValueType<Flag extends FeatureFlag> = DefaultFeatureFlagValueType[Flag];
|
||||
|
||||
export function getFeatureFlagValue<Flag extends FeatureFlag>(
|
||||
serverConfig: ServerConfig | null,
|
||||
flag: Flag,
|
||||
) {
|
||||
if (serverConfig?.featureStates == null || serverConfig.featureStates[flag] == null) {
|
||||
return DefaultFeatureFlagValue[flag];
|
||||
}
|
||||
|
||||
return serverConfig.featureStates[flag] as FeatureFlagValueType<Flag>;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { Decryptable } from "@bitwarden/common/platform/interfaces/decryptable.interface";
|
||||
import { InitializerMetadata } from "@bitwarden/common/platform/interfaces/initializer-metadata.interface";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { OnServerConfigChange } from "../../../platform/abstractions/config/config.service";
|
||||
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
|
||||
import { Decryptable } from "../../../platform/interfaces/decryptable.interface";
|
||||
import { InitializerMetadata } from "../../../platform/interfaces/initializer-metadata.interface";
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
|
||||
export abstract class BulkEncryptService {
|
||||
export abstract class BulkEncryptService implements OnServerConfigChange {
|
||||
abstract decryptItems<T extends InitializerMetadata>(
|
||||
items: Decryptable<T>[],
|
||||
key: SymmetricCryptoKey,
|
||||
): Promise<T[]>;
|
||||
abstract onServerConfigChange(newConfig: ServerConfig): void;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { OnServerConfigChange } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { ServerConfig } from "@bitwarden/common/platform/abstractions/config/server-config";
|
||||
import { Decryptable } from "@bitwarden/common/platform/interfaces/decryptable.interface";
|
||||
import { Encrypted } from "@bitwarden/common/platform/interfaces/encrypted";
|
||||
import { InitializerMetadata } from "@bitwarden/common/platform/interfaces/initializer-metadata.interface";
|
||||
@@ -5,7 +7,7 @@ import { EncArrayBuffer } from "@bitwarden/common/platform/models/domain/enc-arr
|
||||
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
|
||||
export abstract class EncryptService {
|
||||
export abstract class EncryptService implements OnServerConfigChange {
|
||||
abstract encrypt(plainValue: string | Uint8Array, key: SymmetricCryptoKey): Promise<EncString>;
|
||||
abstract encryptToBytes(plainValue: Uint8Array, key: SymmetricCryptoKey): Promise<EncArrayBuffer>;
|
||||
/**
|
||||
@@ -55,4 +57,5 @@ export abstract class EncryptService {
|
||||
value: string | Uint8Array,
|
||||
algorithm: "sha1" | "sha256" | "sha512",
|
||||
): Promise<string>;
|
||||
abstract onServerConfigChange(newConfig: ServerConfig): void;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,10 @@ import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { getClassInitializer } from "@bitwarden/common/platform/services/cryptography/get-class-initializer";
|
||||
|
||||
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
|
||||
|
||||
import { buildDecryptMessage, buildSetConfigMessage } from "./encrypt.worker";
|
||||
|
||||
// TTL (time to live) is not strictly required but avoids tying up memory resources if inactive
|
||||
const workerTTL = 60000; // 1 minute
|
||||
const maxWorkers = 8;
|
||||
@@ -57,6 +61,13 @@ export class BulkEncryptServiceImplementation implements BulkEncryptService {
|
||||
return decryptedItems;
|
||||
}
|
||||
|
||||
onServerConfigChange(newConfig: ServerConfig): void {
|
||||
this.workers.forEach((worker) => {
|
||||
const request = buildSetConfigMessage({ newConfig });
|
||||
worker.postMessage(request);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends items to a set of web workers to decrypt them. This utilizes multiple workers to decrypt items
|
||||
* faster without interrupting other operations (e.g. updating UI).
|
||||
@@ -108,17 +119,18 @@ export class BulkEncryptServiceImplementation implements BulkEncryptService {
|
||||
itemsForWorker.push(...items.slice(end));
|
||||
}
|
||||
|
||||
const request = {
|
||||
id: Utils.newGuid(),
|
||||
const id = Utils.newGuid();
|
||||
const request = buildDecryptMessage({
|
||||
id,
|
||||
items: itemsForWorker,
|
||||
key: key,
|
||||
};
|
||||
});
|
||||
|
||||
worker.postMessage(JSON.stringify(request));
|
||||
worker.postMessage(request);
|
||||
results.push(
|
||||
firstValueFrom(
|
||||
fromEvent(worker, "message").pipe(
|
||||
filter((response: MessageEvent) => response.data?.id === request.id),
|
||||
filter((response: MessageEvent) => response.data?.id === id),
|
||||
map((response) => JSON.parse(response.data.items)),
|
||||
map((items) =>
|
||||
items.map((jsonItem: Jsonify<T>) => {
|
||||
|
||||
@@ -14,16 +14,31 @@ import { EncArrayBuffer } from "@bitwarden/common/platform/models/domain/enc-arr
|
||||
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import { EncryptedObject } from "@bitwarden/common/platform/models/domain/encrypted-object";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { PureCrypto } from "@bitwarden/sdk-internal";
|
||||
|
||||
import {
|
||||
DefaultFeatureFlagValue,
|
||||
FeatureFlag,
|
||||
getFeatureFlagValue,
|
||||
} from "../../../enums/feature-flag.enum";
|
||||
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
|
||||
import { EncryptService } from "../abstractions/encrypt.service";
|
||||
|
||||
export class EncryptServiceImplementation implements EncryptService {
|
||||
private useSDKForDecryption: boolean = DefaultFeatureFlagValue[FeatureFlag.UseSDKForDecryption];
|
||||
|
||||
constructor(
|
||||
protected cryptoFunctionService: CryptoFunctionService,
|
||||
protected logService: LogService,
|
||||
protected logMacFailures: boolean,
|
||||
) {}
|
||||
|
||||
onServerConfigChange(newConfig: ServerConfig): void {
|
||||
const old = this.useSDKForDecryption;
|
||||
this.useSDKForDecryption = getFeatureFlagValue(newConfig, FeatureFlag.UseSDKForDecryption);
|
||||
this.logService.debug("updated sdk decryption flag", old, this.useSDKForDecryption);
|
||||
}
|
||||
|
||||
async encrypt(plainValue: string | Uint8Array, key: SymmetricCryptoKey): Promise<EncString> {
|
||||
if (key == null) {
|
||||
throw new Error("No encryption key provided.");
|
||||
@@ -53,20 +68,7 @@ export class EncryptServiceImplementation implements EncryptService {
|
||||
}
|
||||
|
||||
const encValue = await this.aesEncrypt(plainValue, key);
|
||||
let macLen = 0;
|
||||
if (encValue.mac != null) {
|
||||
macLen = encValue.mac.byteLength;
|
||||
}
|
||||
|
||||
const encBytes = new Uint8Array(1 + encValue.iv.byteLength + macLen + encValue.data.byteLength);
|
||||
encBytes.set([encValue.key.encType]);
|
||||
encBytes.set(new Uint8Array(encValue.iv), 1);
|
||||
if (encValue.mac != null) {
|
||||
encBytes.set(new Uint8Array(encValue.mac), 1 + encValue.iv.byteLength);
|
||||
}
|
||||
|
||||
encBytes.set(new Uint8Array(encValue.data), 1 + encValue.iv.byteLength + macLen);
|
||||
return new EncArrayBuffer(encBytes);
|
||||
return EncArrayBuffer.fromParts(encValue.key.encType, encValue.iv, encValue.data, encValue.mac);
|
||||
}
|
||||
|
||||
async decryptToUtf8(
|
||||
@@ -74,6 +76,15 @@ export class EncryptServiceImplementation implements EncryptService {
|
||||
key: SymmetricCryptoKey,
|
||||
decryptContext: string = "no context",
|
||||
): Promise<string> {
|
||||
if (this.useSDKForDecryption) {
|
||||
this.logService.debug("decrypting with SDK");
|
||||
if (encString == null || encString.encryptedString == null) {
|
||||
throw new Error("encString is null or undefined");
|
||||
}
|
||||
return PureCrypto.symmetric_decrypt(encString.encryptedString, key.keyB64);
|
||||
}
|
||||
this.logService.debug("decrypting with javascript");
|
||||
|
||||
if (key == null) {
|
||||
throw new Error("No key provided for decryption.");
|
||||
}
|
||||
@@ -137,6 +148,25 @@ export class EncryptServiceImplementation implements EncryptService {
|
||||
key: SymmetricCryptoKey,
|
||||
decryptContext: string = "no context",
|
||||
): Promise<Uint8Array | null> {
|
||||
if (this.useSDKForDecryption) {
|
||||
this.logService.debug("decrypting bytes with SDK");
|
||||
if (
|
||||
encThing.encryptionType == null ||
|
||||
encThing.ivBytes == null ||
|
||||
encThing.dataBytes == null
|
||||
) {
|
||||
throw new Error("Cannot decrypt, missing type, IV, or data bytes.");
|
||||
}
|
||||
const buffer = EncArrayBuffer.fromParts(
|
||||
encThing.encryptionType,
|
||||
encThing.ivBytes,
|
||||
encThing.dataBytes,
|
||||
encThing.macBytes,
|
||||
).buffer;
|
||||
return PureCrypto.symmetric_decrypt_array_buffer(buffer, key.keyB64);
|
||||
}
|
||||
this.logService.debug("decrypting bytes with javascript");
|
||||
|
||||
if (key == null) {
|
||||
throw new Error("No encryption key provided.");
|
||||
}
|
||||
|
||||
@@ -9,19 +9,48 @@ import { ContainerService } from "@bitwarden/common/platform/services/container.
|
||||
import { getClassInitializer } from "@bitwarden/common/platform/services/cryptography/get-class-initializer";
|
||||
import { WebCryptoFunctionService } from "@bitwarden/common/platform/services/web-crypto-function.service";
|
||||
|
||||
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
|
||||
import { LogService } from "../../../platform/abstractions/log.service";
|
||||
|
||||
import { EncryptServiceImplementation } from "./encrypt.service.implementation";
|
||||
|
||||
const workerApi: Worker = self as any;
|
||||
|
||||
let inited = false;
|
||||
let encryptService: EncryptServiceImplementation;
|
||||
let logService: LogService;
|
||||
|
||||
const DECRYPT_COMMAND_SHELL = Object.freeze({ command: "decrypt" });
|
||||
const SET_CONFIG_COMMAND_SHELL = Object.freeze({ command: "setConfig" });
|
||||
|
||||
type DecryptCommandData = {
|
||||
id: string;
|
||||
items: Jsonify<Decryptable<any>>[];
|
||||
key: Jsonify<SymmetricCryptoKey>;
|
||||
};
|
||||
|
||||
type SetConfigCommandData = { newConfig: ServerConfig };
|
||||
|
||||
export function buildDecryptMessage(data: DecryptCommandData): string {
|
||||
return JSON.stringify({
|
||||
...data,
|
||||
...DECRYPT_COMMAND_SHELL,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildSetConfigMessage(data: SetConfigCommandData): string {
|
||||
return JSON.stringify({
|
||||
...data,
|
||||
...SET_CONFIG_COMMAND_SHELL,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap the worker environment with services required for decryption
|
||||
*/
|
||||
export function init() {
|
||||
const cryptoFunctionService = new WebCryptoFunctionService(self);
|
||||
const logService = new ConsoleLogService(false);
|
||||
logService = new ConsoleLogService(false);
|
||||
encryptService = new EncryptServiceImplementation(cryptoFunctionService, logService, true);
|
||||
|
||||
const bitwardenContainerService = new ContainerService(null, encryptService);
|
||||
@@ -39,11 +68,20 @@ workerApi.addEventListener("message", async (event: { data: string }) => {
|
||||
}
|
||||
|
||||
const request: {
|
||||
id: string;
|
||||
items: Jsonify<Decryptable<any>>[];
|
||||
key: Jsonify<SymmetricCryptoKey>;
|
||||
command: string;
|
||||
} = JSON.parse(event.data);
|
||||
|
||||
switch (request.command) {
|
||||
case DECRYPT_COMMAND_SHELL.command:
|
||||
return await handleDecrypt(request as unknown as DecryptCommandData);
|
||||
case SET_CONFIG_COMMAND_SHELL.command:
|
||||
return await handleSetConfig(request as unknown as SetConfigCommandData);
|
||||
default:
|
||||
logService.error(`unknown worker command`, request.command, request);
|
||||
}
|
||||
});
|
||||
|
||||
async function handleDecrypt(request: DecryptCommandData) {
|
||||
const key = SymmetricCryptoKey.fromJSON(request.key);
|
||||
const items = request.items.map((jsonItem) => {
|
||||
const initializer = getClassInitializer<Decryptable<any>>(jsonItem.initializerKey);
|
||||
@@ -55,4 +93,8 @@ workerApi.addEventListener("message", async (event: { data: string }) => {
|
||||
id: request.id,
|
||||
items: JSON.stringify(result),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSetConfig(request: SetConfigCommandData) {
|
||||
encryptService.onServerConfigChange(request.newConfig);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { BulkEncryptService } from "@bitwarden/common/key-management/crypto/abstractions/bulk-encrypt.service";
|
||||
import { Decryptable } from "@bitwarden/common/platform/interfaces/decryptable.interface";
|
||||
import { InitializerMetadata } from "@bitwarden/common/platform/interfaces/initializer-metadata.interface";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
|
||||
import { BulkEncryptService } from "../../../key-management/crypto/abstractions/bulk-encrypt.service";
|
||||
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
|
||||
import { Decryptable } from "../../../platform/interfaces/decryptable.interface";
|
||||
import { InitializerMetadata } from "../../../platform/interfaces/initializer-metadata.interface";
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { EncryptService } from "../abstractions/encrypt.service";
|
||||
|
||||
/**
|
||||
@@ -33,4 +33,8 @@ export class FallbackBulkEncryptService implements BulkEncryptService {
|
||||
async setFeatureFlagEncryptService(featureFlagEncryptService: BulkEncryptService) {
|
||||
this.featureFlagEncryptService = featureFlagEncryptService;
|
||||
}
|
||||
|
||||
onServerConfigChange(newConfig: ServerConfig): void {
|
||||
(this.featureFlagEncryptService ?? this.encryptService).onServerConfigChange(newConfig);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,10 @@ import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { getClassInitializer } from "@bitwarden/common/platform/services/cryptography/get-class-initializer";
|
||||
|
||||
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
|
||||
|
||||
import { EncryptServiceImplementation } from "./encrypt.service.implementation";
|
||||
import { buildDecryptMessage, buildSetConfigMessage } from "./encrypt.worker";
|
||||
|
||||
// TTL (time to live) is not strictly required but avoids tying up memory resources if inactive
|
||||
const workerTTL = 3 * 60000; // 3 minutes
|
||||
@@ -47,17 +50,18 @@ export class MultithreadEncryptServiceImplementation extends EncryptServiceImple
|
||||
|
||||
this.restartTimeout();
|
||||
|
||||
const request = {
|
||||
id: Utils.newGuid(),
|
||||
const id = Utils.newGuid();
|
||||
const request = buildDecryptMessage({
|
||||
id,
|
||||
items: items,
|
||||
key: key,
|
||||
};
|
||||
});
|
||||
|
||||
this.worker.postMessage(JSON.stringify(request));
|
||||
this.worker.postMessage(request);
|
||||
|
||||
return await firstValueFrom(
|
||||
fromEvent(this.worker, "message").pipe(
|
||||
filter((response: MessageEvent) => response.data?.id === request.id),
|
||||
filter((response: MessageEvent) => response.data?.id === id),
|
||||
map((response) => JSON.parse(response.data.items)),
|
||||
map((items) =>
|
||||
items.map((jsonItem: Jsonify<T>) => {
|
||||
@@ -71,6 +75,15 @@ export class MultithreadEncryptServiceImplementation extends EncryptServiceImple
|
||||
);
|
||||
}
|
||||
|
||||
override onServerConfigChange(newConfig: ServerConfig): void {
|
||||
super.onServerConfigChange(newConfig);
|
||||
|
||||
if (this.worker !== null) {
|
||||
const request = buildSetConfigMessage({ newConfig });
|
||||
this.worker.postMessage(request);
|
||||
}
|
||||
}
|
||||
|
||||
private clear() {
|
||||
this.clear$.next();
|
||||
this.worker?.terminate();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Observable } from "rxjs";
|
||||
import { Observable, Subscription } from "rxjs";
|
||||
import { SemVer } from "semver";
|
||||
|
||||
import { FeatureFlag, FeatureFlagValueType } from "../../../enums/feature-flag.enum";
|
||||
@@ -10,6 +10,8 @@ import { Region } from "../environment.service";
|
||||
|
||||
import { ServerConfig } from "./server-config";
|
||||
|
||||
export type ConfigCallback = (serverConfig: ServerConfig) => void;
|
||||
|
||||
export abstract class ConfigService {
|
||||
/** The server config of the currently active user */
|
||||
serverConfig$: Observable<ServerConfig | null>;
|
||||
@@ -54,4 +56,10 @@ export abstract class ConfigService {
|
||||
* Triggers a check that the config for the currently active user is up-to-date. If it is not, it will be fetched from the server and stored.
|
||||
*/
|
||||
abstract ensureConfigFetched(): Promise<void>;
|
||||
|
||||
abstract broadcastConfigChangesTo(...listeners: OnServerConfigChange[]): Subscription;
|
||||
}
|
||||
|
||||
export interface OnServerConfigChange {
|
||||
onServerConfigChange(newConfig: ServerConfig): void;
|
||||
}
|
||||
|
||||
15
libs/common/src/platform/enums/encryption-type.enum.spec.ts
Normal file
15
libs/common/src/platform/enums/encryption-type.enum.spec.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import {
|
||||
AsymmetricEncryptionTypes,
|
||||
EncryptionType,
|
||||
SymmetricEncryptionTypes,
|
||||
} from "./encryption-type.enum";
|
||||
|
||||
describe("EncryptionType", () => {
|
||||
it("classifies all types as symmetric or asymmetric", () => {
|
||||
const nSymmetric = SymmetricEncryptionTypes.length;
|
||||
const nAsymmetric = AsymmetricEncryptionTypes.length;
|
||||
const nTotal = nSymmetric + nAsymmetric;
|
||||
// enums are indexable by string and number
|
||||
expect(Object.keys(EncryptionType).length).toEqual(nTotal * 2);
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,19 @@ export enum EncryptionType {
|
||||
Rsa2048_OaepSha1_HmacSha256_B64 = 6,
|
||||
}
|
||||
|
||||
export const SymmetricEncryptionTypes = [
|
||||
EncryptionType.AesCbc256_B64,
|
||||
EncryptionType.AesCbc128_HmacSha256_B64,
|
||||
EncryptionType.AesCbc256_HmacSha256_B64,
|
||||
] as const;
|
||||
|
||||
export const AsymmetricEncryptionTypes = [
|
||||
EncryptionType.Rsa2048_OaepSha256_B64,
|
||||
EncryptionType.Rsa2048_OaepSha1_B64,
|
||||
EncryptionType.Rsa2048_OaepSha256_HmacSha256_B64,
|
||||
EncryptionType.Rsa2048_OaepSha1_HmacSha256_B64,
|
||||
] as const;
|
||||
|
||||
export function encryptionTypeToString(encryptionType: EncryptionType): string {
|
||||
if (encryptionType in EncryptionType) {
|
||||
return EncryptionType[encryptionType];
|
||||
|
||||
@@ -2,7 +2,7 @@ import { EncryptionType } from "../enums";
|
||||
|
||||
export interface Encrypted {
|
||||
encryptionType?: EncryptionType;
|
||||
dataBytes: Uint8Array;
|
||||
macBytes: Uint8Array;
|
||||
ivBytes: Uint8Array;
|
||||
dataBytes: Uint8Array | null;
|
||||
macBytes: Uint8Array | null | undefined;
|
||||
ivBytes: Uint8Array | null;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { makeStaticByteArray } from "../../../../spec";
|
||||
import { EncryptionType } from "../../enums";
|
||||
import {
|
||||
EncryptionType,
|
||||
SymmetricEncryptionTypes,
|
||||
AsymmetricEncryptionTypes,
|
||||
encryptionTypeToString,
|
||||
} from "../../enums";
|
||||
|
||||
import { EncArrayBuffer } from "./enc-array-buffer";
|
||||
|
||||
@@ -71,4 +76,66 @@ describe("encArrayBuffer", () => {
|
||||
const bytes = makeStaticByteArray(50, 9);
|
||||
expect(() => new EncArrayBuffer(bytes)).toThrow("Error parsing encrypted ArrayBuffer");
|
||||
});
|
||||
|
||||
describe("fromParts factory", () => {
|
||||
const plainValue = makeStaticByteArray(16, 1);
|
||||
|
||||
it("throws if required data is null", () => {
|
||||
expect(() =>
|
||||
EncArrayBuffer.fromParts(EncryptionType.AesCbc128_HmacSha256_B64, plainValue, null!, null),
|
||||
).toThrow("encryptionType, iv, and data must be provided");
|
||||
expect(() =>
|
||||
EncArrayBuffer.fromParts(EncryptionType.AesCbc128_HmacSha256_B64, null!, plainValue, null),
|
||||
).toThrow("encryptionType, iv, and data must be provided");
|
||||
expect(() => EncArrayBuffer.fromParts(null!, plainValue, plainValue, null)).toThrow(
|
||||
"encryptionType, iv, and data must be provided",
|
||||
);
|
||||
});
|
||||
|
||||
it.each(SymmetricEncryptionTypes.map((t) => encryptionTypeToString(t)))(
|
||||
"works for %s",
|
||||
async (typeName) => {
|
||||
const type = EncryptionType[typeName as keyof typeof EncryptionType];
|
||||
const iv = plainValue;
|
||||
const mac = type === EncryptionType.AesCbc256_B64 ? null : makeStaticByteArray(32, 20);
|
||||
const data = plainValue;
|
||||
|
||||
const actual = EncArrayBuffer.fromParts(type, iv, data, mac);
|
||||
|
||||
expect(actual.encryptionType).toEqual(type);
|
||||
expect(actual.ivBytes).toEqual(iv);
|
||||
expect(actual.macBytes).toEqual(mac);
|
||||
expect(actual.dataBytes).toEqual(data);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(SymmetricEncryptionTypes.filter((t) => t !== EncryptionType.AesCbc256_B64))(
|
||||
"validates mac length for %s",
|
||||
(type) => {
|
||||
const iv = plainValue;
|
||||
const mac = makeStaticByteArray(1, 20);
|
||||
const data = plainValue;
|
||||
|
||||
expect(() => EncArrayBuffer.fromParts(type, iv, data, mac)).toThrow("Invalid MAC length");
|
||||
},
|
||||
);
|
||||
|
||||
it.each(SymmetricEncryptionTypes.map((t) => encryptionTypeToString(t)))(
|
||||
"requires or forbids mac for %s",
|
||||
async (typeName) => {
|
||||
const type = EncryptionType[typeName as keyof typeof EncryptionType];
|
||||
const iv = makeStaticByteArray(16, 10);
|
||||
const mac = type === EncryptionType.AesCbc256_B64 ? makeStaticByteArray(32, 20) : null;
|
||||
const data = plainValue;
|
||||
|
||||
expect(() => EncArrayBuffer.fromParts(type, iv, data, mac)).toThrow();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(AsymmetricEncryptionTypes)("throws for async type %s", (type) => {
|
||||
expect(() => EncArrayBuffer.fromParts(type, plainValue, plainValue, null)).toThrow(
|
||||
`Unknown EncryptionType ${type} for EncArrayBuffer.fromParts`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Utils } from "../../../platform/misc/utils";
|
||||
import { EncryptionType } from "../../enums";
|
||||
import { Encrypted } from "../../interfaces/encrypted";
|
||||
@@ -10,52 +8,86 @@ const MAC_LENGTH = 32;
|
||||
const MIN_DATA_LENGTH = 1;
|
||||
|
||||
export class EncArrayBuffer implements Encrypted {
|
||||
readonly encryptionType: EncryptionType = null;
|
||||
readonly dataBytes: Uint8Array = null;
|
||||
readonly ivBytes: Uint8Array = null;
|
||||
readonly macBytes: Uint8Array = null;
|
||||
readonly encryptionType: EncryptionType;
|
||||
readonly dataBytes: Uint8Array;
|
||||
readonly ivBytes: Uint8Array;
|
||||
readonly macBytes: Uint8Array | null = null;
|
||||
private static readonly DecryptionError = new Error(
|
||||
"Error parsing encrypted ArrayBuffer: data is corrupted or has an invalid format.",
|
||||
);
|
||||
|
||||
constructor(readonly buffer: Uint8Array) {
|
||||
const encBytes = buffer;
|
||||
const encType = encBytes[0];
|
||||
if (buffer == null) {
|
||||
throw new Error("EncArrayBuffer initialized with null buffer.");
|
||||
}
|
||||
|
||||
switch (encType) {
|
||||
this.encryptionType = this.buffer[0];
|
||||
|
||||
switch (this.encryptionType) {
|
||||
case EncryptionType.AesCbc128_HmacSha256_B64:
|
||||
case EncryptionType.AesCbc256_HmacSha256_B64: {
|
||||
const minimumLength = ENC_TYPE_LENGTH + IV_LENGTH + MAC_LENGTH + MIN_DATA_LENGTH;
|
||||
if (encBytes.length < minimumLength) {
|
||||
this.throwDecryptionError();
|
||||
if (this.buffer.length < minimumLength) {
|
||||
throw EncArrayBuffer.DecryptionError;
|
||||
}
|
||||
|
||||
this.ivBytes = encBytes.slice(ENC_TYPE_LENGTH, ENC_TYPE_LENGTH + IV_LENGTH);
|
||||
this.macBytes = encBytes.slice(
|
||||
this.ivBytes = this.buffer.slice(ENC_TYPE_LENGTH, ENC_TYPE_LENGTH + IV_LENGTH);
|
||||
this.macBytes = this.buffer.slice(
|
||||
ENC_TYPE_LENGTH + IV_LENGTH,
|
||||
ENC_TYPE_LENGTH + IV_LENGTH + MAC_LENGTH,
|
||||
);
|
||||
this.dataBytes = encBytes.slice(ENC_TYPE_LENGTH + IV_LENGTH + MAC_LENGTH);
|
||||
this.dataBytes = this.buffer.slice(ENC_TYPE_LENGTH + IV_LENGTH + MAC_LENGTH);
|
||||
break;
|
||||
}
|
||||
case EncryptionType.AesCbc256_B64: {
|
||||
const minimumLength = ENC_TYPE_LENGTH + IV_LENGTH + MIN_DATA_LENGTH;
|
||||
if (encBytes.length < minimumLength) {
|
||||
this.throwDecryptionError();
|
||||
if (this.buffer.length < minimumLength) {
|
||||
throw EncArrayBuffer.DecryptionError;
|
||||
}
|
||||
|
||||
this.ivBytes = encBytes.slice(ENC_TYPE_LENGTH, ENC_TYPE_LENGTH + IV_LENGTH);
|
||||
this.dataBytes = encBytes.slice(ENC_TYPE_LENGTH + IV_LENGTH);
|
||||
this.ivBytes = this.buffer.slice(ENC_TYPE_LENGTH, ENC_TYPE_LENGTH + IV_LENGTH);
|
||||
this.dataBytes = this.buffer.slice(ENC_TYPE_LENGTH + IV_LENGTH);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
this.throwDecryptionError();
|
||||
throw EncArrayBuffer.DecryptionError;
|
||||
}
|
||||
|
||||
this.encryptionType = encType;
|
||||
}
|
||||
|
||||
private throwDecryptionError() {
|
||||
throw new Error(
|
||||
"Error parsing encrypted ArrayBuffer: data is corrupted or has an invalid format.",
|
||||
);
|
||||
static fromParts(
|
||||
encryptionType: EncryptionType,
|
||||
iv: Uint8Array,
|
||||
data: Uint8Array,
|
||||
mac: Uint8Array | undefined | null,
|
||||
) {
|
||||
if (encryptionType == null || iv == null || data == null) {
|
||||
throw new Error("encryptionType, iv, and data must be provided");
|
||||
}
|
||||
|
||||
switch (encryptionType) {
|
||||
case EncryptionType.AesCbc256_B64:
|
||||
case EncryptionType.AesCbc128_HmacSha256_B64:
|
||||
case EncryptionType.AesCbc256_HmacSha256_B64:
|
||||
EncArrayBuffer.validateIvLength(iv);
|
||||
EncArrayBuffer.validateMacLength(encryptionType, mac);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown EncryptionType ${encryptionType} for EncArrayBuffer.fromParts`);
|
||||
}
|
||||
|
||||
let macLen = 0;
|
||||
if (mac != null) {
|
||||
macLen = mac.length;
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(1 + iv.byteLength + macLen + data.byteLength);
|
||||
bytes.set([encryptionType], 0);
|
||||
bytes.set(iv, 1);
|
||||
if (mac != null) {
|
||||
bytes.set(mac, 1 + iv.byteLength);
|
||||
}
|
||||
bytes.set(data, 1 + iv.byteLength + macLen);
|
||||
return new EncArrayBuffer(bytes);
|
||||
}
|
||||
|
||||
static async fromResponse(response: {
|
||||
@@ -72,4 +104,28 @@ export class EncArrayBuffer implements Encrypted {
|
||||
const buffer = Utils.fromB64ToArray(b64);
|
||||
return new EncArrayBuffer(buffer);
|
||||
}
|
||||
|
||||
static validateIvLength(iv: Uint8Array) {
|
||||
if (iv == null || iv.length !== IV_LENGTH) {
|
||||
throw new Error("Invalid IV length");
|
||||
}
|
||||
}
|
||||
|
||||
static validateMacLength(encType: EncryptionType, mac: Uint8Array | null | undefined) {
|
||||
switch (encType) {
|
||||
case EncryptionType.AesCbc256_B64:
|
||||
if (mac != null) {
|
||||
throw new Error("mac must not be provided for AesCbc256_B64");
|
||||
}
|
||||
break;
|
||||
case EncryptionType.AesCbc128_HmacSha256_B64:
|
||||
case EncryptionType.AesCbc256_HmacSha256_B64:
|
||||
if (mac == null || mac.length !== MAC_LENGTH) {
|
||||
throw new Error("Invalid MAC length");
|
||||
}
|
||||
break;
|
||||
default:
|
||||
throw new Error("Invalid encryption type and mac combination");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Jsonify, Opaque } from "type-fest";
|
||||
|
||||
import { EncryptService } from "../../../key-management/crypto/abstractions/encrypt.service";
|
||||
@@ -17,7 +15,7 @@ export class EncString implements Encrypted {
|
||||
decryptedValue?: string;
|
||||
data?: string;
|
||||
iv?: string;
|
||||
mac?: string;
|
||||
mac: string | undefined | null;
|
||||
|
||||
constructor(
|
||||
encryptedStringOrType: string | EncryptionType,
|
||||
@@ -32,15 +30,15 @@ export class EncString implements Encrypted {
|
||||
}
|
||||
}
|
||||
|
||||
get ivBytes(): Uint8Array {
|
||||
get ivBytes(): Uint8Array | null {
|
||||
return this.iv == null ? null : Utils.fromB64ToArray(this.iv);
|
||||
}
|
||||
|
||||
get macBytes(): Uint8Array {
|
||||
get macBytes(): Uint8Array | null {
|
||||
return this.mac == null ? null : Utils.fromB64ToArray(this.mac);
|
||||
}
|
||||
|
||||
get dataBytes(): Uint8Array {
|
||||
get dataBytes(): Uint8Array | null {
|
||||
return this.data == null ? null : Utils.fromB64ToArray(this.data);
|
||||
}
|
||||
|
||||
@@ -48,7 +46,7 @@ export class EncString implements Encrypted {
|
||||
return this.encryptedString as string;
|
||||
}
|
||||
|
||||
static fromJSON(obj: Jsonify<EncString>): EncString {
|
||||
static fromJSON(obj: Jsonify<EncString>): EncString | null {
|
||||
if (obj == null) {
|
||||
return null;
|
||||
}
|
||||
@@ -56,7 +54,12 @@ export class EncString implements Encrypted {
|
||||
return new EncString(obj);
|
||||
}
|
||||
|
||||
private initFromData(encType: EncryptionType, data: string, iv: string, mac: string) {
|
||||
private initFromData(
|
||||
encType: EncryptionType,
|
||||
data: string,
|
||||
iv: string | undefined,
|
||||
mac: string | undefined,
|
||||
) {
|
||||
if (iv != null) {
|
||||
this.encryptedString = (encType + "." + iv + "|" + data) as EncryptedString;
|
||||
} else {
|
||||
@@ -119,15 +122,13 @@ export class EncString implements Encrypted {
|
||||
} {
|
||||
const headerPieces = encryptedString.split(".");
|
||||
let encType: EncryptionType;
|
||||
let encPieces: string[] = null;
|
||||
let encPieces: string[];
|
||||
|
||||
if (headerPieces.length === 2) {
|
||||
try {
|
||||
encType = parseInt(headerPieces[0], null);
|
||||
encType = parseInt(headerPieces[0]);
|
||||
encPieces = headerPieces[1].split("|");
|
||||
// FIXME: Remove when updating file. Eslint update
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
} catch (e) {
|
||||
} catch {
|
||||
return { encType: NaN, encPieces: [] };
|
||||
}
|
||||
} else {
|
||||
@@ -160,7 +161,7 @@ export class EncString implements Encrypted {
|
||||
|
||||
async decrypt(
|
||||
orgId: string | null,
|
||||
key: SymmetricCryptoKey = null,
|
||||
key: SymmetricCryptoKey | null = null,
|
||||
context?: string,
|
||||
): Promise<string> {
|
||||
if (this.decryptedValue != null) {
|
||||
@@ -219,7 +220,7 @@ export class EncString implements Encrypted {
|
||||
|
||||
return this.decryptedValue;
|
||||
}
|
||||
private async getKeyForDecryption(orgId: string) {
|
||||
private async getKeyForDecryption(orgId: string | null) {
|
||||
const keyService = Utils.getContainerService().getKeyService();
|
||||
return orgId != null
|
||||
? await keyService.getOrgKey(orgId)
|
||||
|
||||
@@ -361,8 +361,6 @@ describe("ConfigService", () => {
|
||||
|
||||
const configs = await firstValueFrom(sut.serverConfig$.pipe(bufferCount(2)));
|
||||
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
|
||||
expect(configs[0].gitHash).toBe("existing-data");
|
||||
expect(configs[1].gitHash).toBe("slow-response");
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
of,
|
||||
shareReplay,
|
||||
Subject,
|
||||
Subscription,
|
||||
switchMap,
|
||||
tap,
|
||||
} from "rxjs";
|
||||
@@ -17,14 +18,14 @@ import { SemVer } from "semver";
|
||||
|
||||
import { AuthService } from "../../../auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
|
||||
import {
|
||||
DefaultFeatureFlagValue,
|
||||
FeatureFlag,
|
||||
FeatureFlagValueType,
|
||||
} from "../../../enums/feature-flag.enum";
|
||||
import { FeatureFlag, getFeatureFlagValue } from "../../../enums/feature-flag.enum";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { ConfigApiServiceAbstraction } from "../../abstractions/config/config-api.service.abstraction";
|
||||
import { ConfigService } from "../../abstractions/config/config.service";
|
||||
import {
|
||||
ConfigCallback,
|
||||
ConfigService,
|
||||
OnServerConfigChange,
|
||||
} from "../../abstractions/config/config.service";
|
||||
import { ServerConfig } from "../../abstractions/config/server-config";
|
||||
import { Environment, EnvironmentService, Region } from "../../abstractions/environment.service";
|
||||
import { LogService } from "../../abstractions/log.service";
|
||||
@@ -57,6 +58,7 @@ export const GLOBAL_SERVER_CONFIGURATIONS = KeyDefinition.record<ServerConfig, A
|
||||
// FIXME: currently we are limited to api requests for active users. Update to accept a UserId and APIUrl once ApiService supports it.
|
||||
export class DefaultConfigService implements ConfigService {
|
||||
private failedFetchFallbackSubject = new Subject<ServerConfig>();
|
||||
private callbacks: ConfigCallback[] = [];
|
||||
|
||||
serverConfig$: Observable<ServerConfig>;
|
||||
|
||||
@@ -123,26 +125,13 @@ export class DefaultConfigService implements ConfigService {
|
||||
}
|
||||
|
||||
getFeatureFlag$<Flag extends FeatureFlag>(key: Flag) {
|
||||
return this.serverConfig$.pipe(
|
||||
map((serverConfig) => this.getFeatureFlagValue(serverConfig, key)),
|
||||
);
|
||||
}
|
||||
|
||||
private getFeatureFlagValue<Flag extends FeatureFlag>(
|
||||
serverConfig: ServerConfig | null,
|
||||
flag: Flag,
|
||||
) {
|
||||
if (serverConfig?.featureStates == null || serverConfig.featureStates[flag] == null) {
|
||||
return DefaultFeatureFlagValue[flag];
|
||||
}
|
||||
|
||||
return serverConfig.featureStates[flag] as FeatureFlagValueType<Flag>;
|
||||
return this.serverConfig$.pipe(map((serverConfig) => getFeatureFlagValue(serverConfig, key)));
|
||||
}
|
||||
|
||||
userCachedFeatureFlag$<Flag extends FeatureFlag>(key: Flag, userId: UserId) {
|
||||
return this.stateProvider
|
||||
.getUser(userId, USER_SERVER_CONFIG)
|
||||
.state$.pipe(map((config) => this.getFeatureFlagValue(config, key)));
|
||||
.state$.pipe(map((config) => getFeatureFlagValue(config, key)));
|
||||
}
|
||||
|
||||
async getFeatureFlag<Flag extends FeatureFlag>(key: Flag) {
|
||||
@@ -166,6 +155,12 @@ export class DefaultConfigService implements ConfigService {
|
||||
await firstValueFrom(this.serverConfig$);
|
||||
}
|
||||
|
||||
broadcastConfigChangesTo(...listeners: OnServerConfigChange[]): Subscription {
|
||||
return this.serverConfig$.subscribe((config) =>
|
||||
listeners.forEach((listener) => listener.onServerConfigChange(config)),
|
||||
);
|
||||
}
|
||||
|
||||
private olderThanRetrievalInterval(date: Date) {
|
||||
return new Date().getTime() - date.getTime() > RETRIEVAL_INTERVAL;
|
||||
}
|
||||
|
||||
@@ -134,7 +134,6 @@ export class StateService<
|
||||
}
|
||||
|
||||
async addAccount(account: TAccount) {
|
||||
await this.environmentService.seedUserEnvironment(account.profile.userId as UserId);
|
||||
await this.updateState(async (state) => {
|
||||
state.accounts[account.profile.userId] = account;
|
||||
return state;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { OrganizationId, UserId } from "../types/guid";
|
||||
|
||||
/** error emitted when the `SingleUserDependency` changes Ids */
|
||||
export type UserChangedError = {
|
||||
|
||||
@@ -6,6 +6,9 @@ import { DefaultSemanticLogger } from "./default-semantic-logger";
|
||||
import { DisabledSemanticLogger } from "./disabled-semantic-logger";
|
||||
import { SemanticLogger } from "./semantic-logger.abstraction";
|
||||
|
||||
/** A type for injection of a log provider */
|
||||
export type LogProvider = <Context>(context: Jsonify<Context>) => SemanticLogger;
|
||||
|
||||
/** Instantiates a semantic logger that emits nothing when a message
|
||||
* is logged.
|
||||
* @param _context a static payload that is cloned when the logger
|
||||
@@ -25,8 +28,11 @@ export function disabledSemanticLoggerProvider<Context extends object>(
|
||||
* @param settings specializes how the semantic logger functions.
|
||||
* If this is omitted, the logger suppresses debug messages.
|
||||
*/
|
||||
export function consoleSemanticLoggerProvider(logger: LogService): SemanticLogger {
|
||||
return new DefaultSemanticLogger(logger, {});
|
||||
export function consoleSemanticLoggerProvider<Context extends object>(
|
||||
logger: LogService,
|
||||
context: Jsonify<Context>,
|
||||
): SemanticLogger {
|
||||
return new DefaultSemanticLogger(logger, context);
|
||||
}
|
||||
|
||||
/** Instantiates a semantic logger that emits logs to the console.
|
||||
@@ -42,7 +48,7 @@ export function ifEnabledSemanticLoggerProvider<Context extends object>(
|
||||
context: Jsonify<Context>,
|
||||
) {
|
||||
if (enable) {
|
||||
return new DefaultSemanticLogger(logger, context);
|
||||
return consoleSemanticLoggerProvider(logger, context);
|
||||
} else {
|
||||
return disabledSemanticLoggerProvider(context);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export abstract class UserStateSubjectDependencyProvider {
|
||||
/** Provides local object persistence */
|
||||
abstract state: StateProvider;
|
||||
|
||||
// FIXME: remove `log` and inject the system provider into the USS instead
|
||||
/** Provides semantic logging */
|
||||
abstract log: <Context extends object>(_context: Jsonify<Context>) => SemanticLogger;
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ const DEFAULT_FRAME_SIZE = 32;
|
||||
export class UserStateSubject<
|
||||
State extends object,
|
||||
Secret = State,
|
||||
Disclosed = never,
|
||||
Disclosed = Record<string, never>,
|
||||
Dependencies = null,
|
||||
>
|
||||
extends Observable<State>
|
||||
@@ -243,7 +243,7 @@ export class UserStateSubject<
|
||||
// `init$` becomes the accumulator for `scan`
|
||||
init$.pipe(
|
||||
first(),
|
||||
map((init) => [init, null] as const),
|
||||
map((init) => [init, null] as [State, Dependencies]),
|
||||
),
|
||||
input$.pipe(
|
||||
map((constrained) => constrained.state),
|
||||
@@ -256,7 +256,7 @@ export class UserStateSubject<
|
||||
if (shouldUpdate) {
|
||||
// actual update
|
||||
const next = this.context.nextValue?.(prev, pending, dependencies) ?? pending;
|
||||
return [next, dependencies];
|
||||
return [next, dependencies] as const;
|
||||
} else {
|
||||
// false update
|
||||
this.log.debug("shouldUpdate prevented write");
|
||||
|
||||
@@ -980,10 +980,14 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
|
||||
async upsert(cipher: CipherData | CipherData[]): Promise<Record<CipherId, CipherData>> {
|
||||
const ciphers = cipher instanceof CipherData ? [cipher] : cipher;
|
||||
return await this.updateEncryptedCipherState((current) => {
|
||||
const res = await this.updateEncryptedCipherState((current) => {
|
||||
ciphers.forEach((c) => (current[c.id as CipherId] = c));
|
||||
return current;
|
||||
});
|
||||
// Some state storage providers (e.g. Electron) don't update the state immediately, wait for next tick
|
||||
// Otherwise, subscribers to cipherViews$ can get stale data
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
return res;
|
||||
}
|
||||
|
||||
async replace(ciphers: { [id: string]: CipherData }, userId: UserId): Promise<any> {
|
||||
@@ -1000,13 +1004,16 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
userId: UserId = null,
|
||||
): Promise<Record<CipherId, CipherData>> {
|
||||
userId ||= await firstValueFrom(this.stateProvider.activeUserId$);
|
||||
await this.clearDecryptedCiphersState(userId);
|
||||
await this.clearCache(userId);
|
||||
const updatedCiphers = await this.stateProvider
|
||||
.getUser(userId, ENCRYPTED_CIPHERS)
|
||||
.update((current) => {
|
||||
const result = update(current ?? {});
|
||||
return result;
|
||||
});
|
||||
// Some state storage providers (e.g. Electron) don't update the state immediately, wait for next tick
|
||||
// Otherwise, subscribers to cipherViews$ can get stale data
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
return updatedCiphers;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import { ImportResult } from "../models/import-result";
|
||||
export abstract class BaseImporter {
|
||||
organizationId: string = null;
|
||||
|
||||
// FIXME: This should be replaced by injecting the log service.
|
||||
protected logService: LogService = new ConsoleLogService(false);
|
||||
|
||||
protected newLineRegex = /(?:\r\n|\r|\n)/;
|
||||
|
||||
@@ -48,6 +48,8 @@ describe("PasswordXPCsvImporter", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
importer = new PasswordXPCsvImporter();
|
||||
// Importers currently create their own ConsoleLogService. This should be replaced by injecting a test log service.
|
||||
jest.spyOn(console, "warn").mockImplementation();
|
||||
});
|
||||
|
||||
it("should return success false if CSV data is null", async () => {
|
||||
|
||||
@@ -5,6 +5,11 @@ import { data as dataNoFolder } from "./spec-data/roboform-csv/empty-folders";
|
||||
import { data as dataFolder } from "./spec-data/roboform-csv/with-folders";
|
||||
|
||||
describe("Roboform CSV Importer", () => {
|
||||
beforeEach(() => {
|
||||
// Importers currently create their own ConsoleLogService. This should be replaced by injecting a test log service.
|
||||
jest.spyOn(console, "warn").mockImplementation();
|
||||
});
|
||||
|
||||
it("should parse CSV data", async () => {
|
||||
const importer = new RoboFormCsvImporter();
|
||||
const result = await importer.parse(dataNoFolder);
|
||||
|
||||
@@ -51,6 +51,11 @@ const CipherData = [
|
||||
];
|
||||
|
||||
describe("SecureSafe CSV Importer", () => {
|
||||
beforeEach(() => {
|
||||
// Importers currently create their own ConsoleLogService. This should be replaced by injecting a test log service.
|
||||
jest.spyOn(console, "warn").mockImplementation();
|
||||
});
|
||||
|
||||
CipherData.forEach((data) => {
|
||||
it(data.title, async () => {
|
||||
const importer = new SecureSafeCsvImporter();
|
||||
|
||||
10
libs/tools/generator/core/src/metadata/index.ts
Normal file
10
libs/tools/generator/core/src/metadata/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { AlgorithmsByType as ABT } from "./data";
|
||||
import { CredentialType, CredentialAlgorithm } from "./type";
|
||||
|
||||
export const AlgorithmsByType: Record<CredentialType, ReadonlyArray<CredentialAlgorithm>> = ABT;
|
||||
|
||||
export { Profile, Type } from "./data";
|
||||
export { GeneratorMetadata } from "./generator-metadata";
|
||||
export { ProfileContext, CoreProfileMetadata, ProfileMetadata } from "./profile-metadata";
|
||||
export { GeneratorProfile, CredentialAlgorithm, CredentialType } from "./type";
|
||||
export { isForwarderProfile, isForwarderExtensionId } from "./util";
|
||||
@@ -0,0 +1,338 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { BehaviorSubject, ReplaySubject, firstValueFrom } from "rxjs";
|
||||
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
||||
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state";
|
||||
import { LegacyEncryptorProvider } from "@bitwarden/common/tools/cryptography/legacy-encryptor-provider";
|
||||
import { UserEncryptor } from "@bitwarden/common/tools/cryptography/user-encryptor.abstraction";
|
||||
import { disabledSemanticLoggerProvider } from "@bitwarden/common/tools/log";
|
||||
import { PrivateClassifier } from "@bitwarden/common/tools/private-classifier";
|
||||
import { IdentityConstraint } from "@bitwarden/common/tools/state/identity-state-constraint";
|
||||
import { UserStateSubjectDependencyProvider } from "@bitwarden/common/tools/state/user-state-subject-dependency-provider";
|
||||
import { StateConstraints } from "@bitwarden/common/tools/types";
|
||||
import { OrganizationId, PolicyId, UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { FakeStateProvider, FakeAccountService, awaitAsync } from "../../../../../common/spec";
|
||||
import { CoreProfileMetadata, ProfileContext } from "../metadata/profile-metadata";
|
||||
import { GeneratorConstraints } from "../types";
|
||||
|
||||
import { GeneratorProfileProvider } from "./generator-profile-provider";
|
||||
|
||||
// arbitrary settings types
|
||||
type SomeSettings = { foo: string };
|
||||
|
||||
// fake user information
|
||||
const SomeUser = "SomeUser" as UserId;
|
||||
const AnotherUser = "SomeOtherUser" as UserId;
|
||||
const UnverifiedEmailUser = "UnverifiedEmailUser" as UserId;
|
||||
const accounts: Record<UserId, Account> = {
|
||||
[SomeUser]: {
|
||||
id: SomeUser,
|
||||
name: "some user",
|
||||
email: "some.user@example.com",
|
||||
emailVerified: true,
|
||||
},
|
||||
[AnotherUser]: {
|
||||
id: AnotherUser,
|
||||
name: "some other user",
|
||||
email: "some.other.user@example.com",
|
||||
emailVerified: true,
|
||||
},
|
||||
[UnverifiedEmailUser]: {
|
||||
id: UnverifiedEmailUser,
|
||||
name: "a user with an unverfied email",
|
||||
email: "unverified@example.com",
|
||||
emailVerified: false,
|
||||
},
|
||||
};
|
||||
const accountService = new FakeAccountService(accounts);
|
||||
|
||||
const policyService = mock<PolicyService>();
|
||||
const somePolicy = new Policy({
|
||||
data: { fooPolicy: true },
|
||||
type: PolicyType.PasswordGenerator,
|
||||
id: "" as PolicyId,
|
||||
organizationId: "" as OrganizationId,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const stateProvider = new FakeStateProvider(accountService);
|
||||
const encryptor = mock<UserEncryptor>();
|
||||
const encryptorProvider = mock<LegacyEncryptorProvider>();
|
||||
|
||||
const dependencyProvider: UserStateSubjectDependencyProvider = {
|
||||
encryptor: encryptorProvider,
|
||||
state: stateProvider,
|
||||
log: disabledSemanticLoggerProvider,
|
||||
};
|
||||
|
||||
// settings storage location
|
||||
const SettingsKey = new UserKeyDefinition<SomeSettings>(GENERATOR_DISK, "SomeSettings", {
|
||||
deserializer: (value) => value,
|
||||
clearOn: [],
|
||||
});
|
||||
|
||||
// fake the configuration
|
||||
const SomeProfile: CoreProfileMetadata<SomeSettings> = {
|
||||
type: "core",
|
||||
storage: {
|
||||
target: "object",
|
||||
key: "SomeSettings",
|
||||
state: GENERATOR_DISK,
|
||||
classifier: new PrivateClassifier(),
|
||||
format: "plain",
|
||||
options: {
|
||||
deserializer: (value) => value,
|
||||
clearOn: [],
|
||||
},
|
||||
initial: { foo: "initial" },
|
||||
},
|
||||
constraints: {
|
||||
type: PolicyType.PasswordGenerator,
|
||||
default: { foo: {} },
|
||||
create: jest.fn((policies, context) => {
|
||||
const combined = policies.reduce(
|
||||
(acc, policy) => ({ fooPolicy: acc.fooPolicy || policy.data.fooPolicy }),
|
||||
{ fooPolicy: false },
|
||||
);
|
||||
|
||||
if (combined.fooPolicy) {
|
||||
return {
|
||||
constraints: {
|
||||
policyInEffect: true,
|
||||
},
|
||||
calibrate(state: SomeSettings) {
|
||||
return {
|
||||
constraints: {},
|
||||
adjust(state: SomeSettings) {
|
||||
return { foo: `adjusted(${state.foo})` };
|
||||
},
|
||||
fix(state: SomeSettings) {
|
||||
return { foo: `fixed(${state.foo})` };
|
||||
},
|
||||
} satisfies StateConstraints<SomeSettings>;
|
||||
},
|
||||
} satisfies GeneratorConstraints<SomeSettings>;
|
||||
} else {
|
||||
return {
|
||||
constraints: {
|
||||
policyInEffect: false,
|
||||
},
|
||||
adjust(state: SomeSettings) {
|
||||
return state;
|
||||
},
|
||||
fix(state: SomeSettings) {
|
||||
return state;
|
||||
},
|
||||
} satisfies GeneratorConstraints<SomeSettings>;
|
||||
}
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
const NoPolicyProfile: CoreProfileMetadata<SomeSettings> = {
|
||||
type: "core",
|
||||
storage: {
|
||||
target: "object",
|
||||
key: "SomeSettings",
|
||||
state: GENERATOR_DISK,
|
||||
classifier: new PrivateClassifier(),
|
||||
format: "classified",
|
||||
options: {
|
||||
deserializer: (value) => value,
|
||||
clearOn: [],
|
||||
},
|
||||
initial: { foo: "initial" },
|
||||
},
|
||||
constraints: {
|
||||
default: { foo: {} },
|
||||
create: jest.fn((policies, context) => new IdentityConstraint()),
|
||||
},
|
||||
};
|
||||
|
||||
describe("GeneratorProfileProvider", () => {
|
||||
beforeEach(async () => {
|
||||
policyService.getAll$.mockImplementation(() => new BehaviorSubject([]).asObservable());
|
||||
const encryptor$ = new BehaviorSubject({ userId: SomeUser, encryptor });
|
||||
encryptorProvider.userEncryptor$.mockReturnValue(encryptor$);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("settings", () => {
|
||||
it("writes to the user's state", async () => {
|
||||
const account$ = new BehaviorSubject(accounts[SomeUser]).asObservable();
|
||||
const profileProvider = new GeneratorProfileProvider(dependencyProvider, policyService);
|
||||
const settings = profileProvider.settings(SomeProfile, { account$ });
|
||||
|
||||
settings.next({ foo: "next value" });
|
||||
await awaitAsync();
|
||||
const result = await firstValueFrom(stateProvider.getUserState$(SettingsKey, SomeUser));
|
||||
|
||||
expect(result).toEqual({ foo: "next value" });
|
||||
});
|
||||
|
||||
it("waits for the user to become available", async () => {
|
||||
await stateProvider.setUserState(SettingsKey, { foo: "initial value" }, SomeUser);
|
||||
const account = new ReplaySubject<Account>(1);
|
||||
const account$ = account.asObservable();
|
||||
const profileProvider = new GeneratorProfileProvider(dependencyProvider, policyService);
|
||||
|
||||
let result: SomeSettings | undefined = undefined;
|
||||
profileProvider.settings(SomeProfile, { account$ }).subscribe({
|
||||
next(settings) {
|
||||
result = settings;
|
||||
},
|
||||
});
|
||||
await awaitAsync();
|
||||
expect(result).toBeUndefined();
|
||||
account.next(accounts[SomeUser]);
|
||||
await awaitAsync();
|
||||
|
||||
// need to use `!` because TypeScript isn't aware that the subscription
|
||||
// sets `result`, and thus computes the type of `result?.userId` as `never`
|
||||
expect(result).toEqual({ foo: "initial value" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("constraints$", () => {
|
||||
it("creates constraints without policy in effect when there is no policy", async () => {
|
||||
const profileProvider = new GeneratorProfileProvider(dependencyProvider, policyService);
|
||||
const account$ = new BehaviorSubject(accounts[SomeUser]).asObservable();
|
||||
|
||||
const result = await firstValueFrom(profileProvider.constraints$(SomeProfile, { account$ }));
|
||||
|
||||
expect(result.constraints.policyInEffect).toBeFalsy();
|
||||
});
|
||||
|
||||
it("creates constraints with policy in effect when there is a policy", async () => {
|
||||
const profileProvider = new GeneratorProfileProvider(dependencyProvider, policyService);
|
||||
const account$ = new BehaviorSubject(accounts[SomeUser]).asObservable();
|
||||
const policy$ = new BehaviorSubject([somePolicy]);
|
||||
policyService.getAll$.mockReturnValue(policy$);
|
||||
|
||||
const result = await firstValueFrom(profileProvider.constraints$(SomeProfile, { account$ }));
|
||||
|
||||
expect(result.constraints.policyInEffect).toBeTruthy();
|
||||
});
|
||||
|
||||
it("sends the policy list to profile.constraint.create(...) when a type is specified", async () => {
|
||||
const profileProvider = new GeneratorProfileProvider(dependencyProvider, policyService);
|
||||
const account$ = new BehaviorSubject(accounts[SomeUser]).asObservable();
|
||||
const expectedPolicy = [somePolicy];
|
||||
const policy$ = new BehaviorSubject(expectedPolicy);
|
||||
policyService.getAll$.mockReturnValue(policy$);
|
||||
|
||||
await firstValueFrom(profileProvider.constraints$(SomeProfile, { account$ }));
|
||||
|
||||
expect(SomeProfile.constraints.create).toHaveBeenCalledWith(
|
||||
expectedPolicy,
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("sends an empty policy list to profile.constraint.create(...) when a type is omitted", async () => {
|
||||
const profileProvider = new GeneratorProfileProvider(dependencyProvider, policyService);
|
||||
const account$ = new BehaviorSubject(accounts[SomeUser]).asObservable();
|
||||
|
||||
await firstValueFrom(profileProvider.constraints$(NoPolicyProfile, { account$ }));
|
||||
|
||||
expect(NoPolicyProfile.constraints.create).toHaveBeenCalledWith([], expect.any(Object));
|
||||
});
|
||||
|
||||
it("sends the context to profile.constraint.create(...)", async () => {
|
||||
const profileProvider = new GeneratorProfileProvider(dependencyProvider, policyService);
|
||||
const account$ = new BehaviorSubject(accounts[SomeUser]).asObservable();
|
||||
const expectedContext: ProfileContext<SomeSettings> = {
|
||||
defaultConstraints: NoPolicyProfile.constraints.default,
|
||||
email: accounts[SomeUser].email,
|
||||
};
|
||||
|
||||
await firstValueFrom(profileProvider.constraints$(NoPolicyProfile, { account$ }));
|
||||
|
||||
expect(NoPolicyProfile.constraints.create).toHaveBeenCalledWith(
|
||||
expect.any(Array),
|
||||
expectedContext,
|
||||
);
|
||||
});
|
||||
|
||||
it("omits nonverified emails from the context sent to profile.constraint.create(...)", async () => {
|
||||
const profileProvider = new GeneratorProfileProvider(dependencyProvider, policyService);
|
||||
const account$ = new BehaviorSubject(accounts[UnverifiedEmailUser]).asObservable();
|
||||
const expectedContext: ProfileContext<SomeSettings> = {
|
||||
defaultConstraints: NoPolicyProfile.constraints.default,
|
||||
};
|
||||
|
||||
await firstValueFrom(profileProvider.constraints$(NoPolicyProfile, { account$ }));
|
||||
|
||||
expect(NoPolicyProfile.constraints.create).toHaveBeenCalledWith(
|
||||
expect.any(Array),
|
||||
expectedContext,
|
||||
);
|
||||
});
|
||||
|
||||
// FIXME: implement this test case once the fake account service mock supports email verification
|
||||
it.todo("invokes profile.constraint.create(...) when the user's email address is verified");
|
||||
|
||||
// FIXME: implement this test case once the fake account service mock supports email updates
|
||||
it.todo("invokes profile.constraint.create(...) when the user's email address changes");
|
||||
|
||||
it("follows policy emissions", async () => {
|
||||
const profileProvider = new GeneratorProfileProvider(dependencyProvider, policyService);
|
||||
const account = new BehaviorSubject(accounts[SomeUser]);
|
||||
const account$ = account.asObservable();
|
||||
const somePolicySubject = new BehaviorSubject([somePolicy]);
|
||||
policyService.getAll$.mockReturnValueOnce(somePolicySubject.asObservable());
|
||||
const emissions: GeneratorConstraints<SomeSettings>[] = [];
|
||||
const sub = profileProvider
|
||||
.constraints$(SomeProfile, { account$ })
|
||||
.subscribe((policy) => emissions.push(policy));
|
||||
|
||||
// swap the active policy for an inactive policy
|
||||
somePolicySubject.next([]);
|
||||
await awaitAsync();
|
||||
sub.unsubscribe();
|
||||
const [someResult, anotherResult] = emissions;
|
||||
|
||||
expect(someResult.constraints.policyInEffect).toBeTruthy();
|
||||
expect(anotherResult.constraints.policyInEffect).toBeFalsy();
|
||||
});
|
||||
|
||||
it("errors when the user errors", async () => {
|
||||
const profileProvider = new GeneratorProfileProvider(dependencyProvider, policyService);
|
||||
const account = new BehaviorSubject(accounts[SomeUser]);
|
||||
const account$ = account.asObservable();
|
||||
const expectedError = { some: "error" };
|
||||
|
||||
let actualError: any = null;
|
||||
profileProvider.constraints$(SomeProfile, { account$ }).subscribe({
|
||||
error: (e: unknown) => {
|
||||
actualError = e;
|
||||
},
|
||||
});
|
||||
account.error(expectedError);
|
||||
await awaitAsync();
|
||||
|
||||
expect(actualError).toEqual(expectedError);
|
||||
});
|
||||
|
||||
it("completes when the user completes", async () => {
|
||||
const profileProvider = new GeneratorProfileProvider(dependencyProvider, policyService);
|
||||
const account = new BehaviorSubject(accounts[SomeUser]);
|
||||
const account$ = account.asObservable();
|
||||
|
||||
let completed = false;
|
||||
profileProvider.constraints$(SomeProfile, { account$ }).subscribe({
|
||||
complete: () => {
|
||||
completed = true;
|
||||
},
|
||||
});
|
||||
account.complete();
|
||||
await awaitAsync();
|
||||
|
||||
expect(completed).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import {
|
||||
distinctUntilChanged,
|
||||
map,
|
||||
Observable,
|
||||
switchMap,
|
||||
takeUntil,
|
||||
shareReplay,
|
||||
tap,
|
||||
of,
|
||||
} from "rxjs";
|
||||
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { BoundDependency } from "@bitwarden/common/tools/dependencies";
|
||||
import { SemanticLogger } from "@bitwarden/common/tools/log";
|
||||
import { anyComplete } from "@bitwarden/common/tools/rx";
|
||||
import { UserStateSubject } from "@bitwarden/common/tools/state/user-state-subject";
|
||||
import { UserStateSubjectDependencyProvider } from "@bitwarden/common/tools/state/user-state-subject-dependency-provider";
|
||||
|
||||
import { ProfileContext, CoreProfileMetadata, ProfileMetadata } from "../metadata";
|
||||
import { GeneratorConstraints } from "../types/generator-constraints";
|
||||
|
||||
/** Surfaces contextual information to credential generators */
|
||||
export class GeneratorProfileProvider {
|
||||
/** Instantiates the context provider
|
||||
* @param providers dependency injectors for user state subjects
|
||||
* @param policyService settings constraint lookups
|
||||
*/
|
||||
constructor(
|
||||
private readonly providers: UserStateSubjectDependencyProvider,
|
||||
private readonly policyService: PolicyService,
|
||||
) {
|
||||
this.log = providers.log({ type: "GeneratorProfileProvider" });
|
||||
}
|
||||
|
||||
private readonly log: SemanticLogger;
|
||||
|
||||
/** Get a subject bound to a specific user's settings for the provided profile.
|
||||
* @param profile determines which profile's settings are loaded
|
||||
* @param dependencies.singleUserId$ identifies the user to which the settings are bound
|
||||
* @returns an observable that emits the subject once `dependencies.singleUserId$` becomes
|
||||
* available and then completes.
|
||||
* @remarks the subject tracks and enforces policy on the settings it contains.
|
||||
* It completes when `dependencies.singleUserId$` competes or the user's encryption key
|
||||
* becomes unavailable.
|
||||
*/
|
||||
settings<Settings extends object>(
|
||||
profile: Readonly<CoreProfileMetadata<Settings>>,
|
||||
dependencies: BoundDependency<"account", Account>,
|
||||
): UserStateSubject<Settings> {
|
||||
const account$ = dependencies.account$.pipe(shareReplay({ bufferSize: 1, refCount: true }));
|
||||
const constraints$ = this.constraints$(profile, { account$ });
|
||||
const subject = new UserStateSubject(profile.storage, this.providers, {
|
||||
constraints$,
|
||||
account$,
|
||||
});
|
||||
|
||||
return subject;
|
||||
}
|
||||
|
||||
/** Get the policy constraints for the provided profile
|
||||
* @param dependencies.account$ constraints are loaded from this account.
|
||||
* If the account's email is verified, it is passed to the constraints
|
||||
* @returns an observable that emits the policy once `dependencies.userId$`
|
||||
* and the policy become available.
|
||||
*/
|
||||
constraints$<Settings>(
|
||||
profile: Readonly<ProfileMetadata<Settings>>,
|
||||
dependencies: BoundDependency<"account", Account>,
|
||||
): Observable<GeneratorConstraints<Settings>> {
|
||||
const account$ = dependencies.account$.pipe(shareReplay({ bufferSize: 1, refCount: true }));
|
||||
|
||||
const constraints$ = account$.pipe(
|
||||
distinctUntilChanged((prev, next) => {
|
||||
return prev.email === next.email && prev.emailVerified === next.emailVerified;
|
||||
}),
|
||||
switchMap((account) => {
|
||||
this.log.debug(
|
||||
{
|
||||
accountId: account.id,
|
||||
profileType: profile.type,
|
||||
policyType: profile.constraints.type ?? "N/A",
|
||||
defaultConstraints: profile.constraints.default as object,
|
||||
},
|
||||
"initializing constraints$",
|
||||
);
|
||||
|
||||
const policies$ = profile.constraints.type
|
||||
? this.policyService.getAll$(profile.constraints.type, account.id)
|
||||
: of([]);
|
||||
|
||||
const context: ProfileContext<Settings> = {
|
||||
defaultConstraints: profile.constraints.default,
|
||||
};
|
||||
if (account.emailVerified) {
|
||||
this.log.debug({ email: account.email }, "verified email detected; including in context");
|
||||
context.email = account.email;
|
||||
}
|
||||
|
||||
const constraints$ = policies$.pipe(
|
||||
map((policies) => profile.constraints.create(policies, context)),
|
||||
tap(() => this.log.debug("constraints created")),
|
||||
);
|
||||
|
||||
return constraints$;
|
||||
}),
|
||||
// complete policy emissions otherwise `switchMap` holds `constraints$`
|
||||
// open indefinitely
|
||||
takeUntil(anyComplete(account$)),
|
||||
);
|
||||
|
||||
return constraints$;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<bit-dialog #dialog dialogSize="large" background="alt">
|
||||
<span bitDialogTitle>
|
||||
{{ headerText }}
|
||||
</span>
|
||||
<span bitDialogContent>
|
||||
<tools-send-form
|
||||
formId="sendForm"
|
||||
[config]="config"
|
||||
(onSendCreated)="onSendCreated($event)"
|
||||
(onSendUpdated)="onSendUpdated($event)"
|
||||
[submitBtn]="submitBtn"
|
||||
>
|
||||
</tools-send-form>
|
||||
</span>
|
||||
|
||||
<ng-container bitDialogFooter>
|
||||
<button bitButton type="submit" form="sendForm" buttonType="primary" #submitBtn>
|
||||
{{ "save" | i18n }}
|
||||
</button>
|
||||
<button bitButton type="button" buttonType="secondary" bitDialogClose>
|
||||
{{ "cancel" | i18n }}
|
||||
</button>
|
||||
<div class="tw-ml-auto">
|
||||
<button
|
||||
*ngIf="config?.mode !== 'add'"
|
||||
type="button"
|
||||
buttonType="danger"
|
||||
slot="end"
|
||||
bitIconButton="bwi-trash"
|
||||
[bitAction]="deleteSend"
|
||||
appA11yTitle="{{ 'delete' | i18n }}"
|
||||
></button>
|
||||
</div>
|
||||
</ng-container>
|
||||
</bit-dialog>
|
||||
@@ -0,0 +1,179 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { DIALOG_DATA, DialogRef } from "@angular/cdk/dialog";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, Inject } from "@angular/core";
|
||||
import { FormsModule } from "@angular/forms";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
|
||||
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
|
||||
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
|
||||
import {
|
||||
AsyncActionsModule,
|
||||
ButtonModule,
|
||||
DialogService,
|
||||
IconButtonModule,
|
||||
SearchModule,
|
||||
ToastService,
|
||||
DialogModule,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
import { SendFormConfig, SendFormMode, SendFormModule } from "../send-form";
|
||||
|
||||
export interface SendItemDialogParams {
|
||||
/**
|
||||
* The configuration object for the dialog and form.
|
||||
*/
|
||||
formConfig: SendFormConfig;
|
||||
|
||||
/**
|
||||
* If true, the "edit" button will be disabled in the dialog.
|
||||
*/
|
||||
disableForm?: boolean;
|
||||
}
|
||||
|
||||
export enum SendItemDialogResult {
|
||||
/**
|
||||
* A Send was saved (created or updated).
|
||||
*/
|
||||
Saved = "saved",
|
||||
|
||||
/**
|
||||
* A Send was deleted.
|
||||
*/
|
||||
Deleted = "deleted",
|
||||
}
|
||||
|
||||
/**
|
||||
* Component for adding or editing a send item.
|
||||
*/
|
||||
@Component({
|
||||
templateUrl: "send-add-edit-dialog.component.html",
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
SearchModule,
|
||||
JslibModule,
|
||||
FormsModule,
|
||||
ButtonModule,
|
||||
IconButtonModule,
|
||||
SendFormModule,
|
||||
AsyncActionsModule,
|
||||
DialogModule,
|
||||
],
|
||||
})
|
||||
export class SendAddEditDialogComponent {
|
||||
/**
|
||||
* The header text for the component.
|
||||
*/
|
||||
headerText: string;
|
||||
|
||||
/**
|
||||
* The configuration for the send form.
|
||||
*/
|
||||
config: SendFormConfig;
|
||||
|
||||
constructor(
|
||||
@Inject(DIALOG_DATA) protected params: SendItemDialogParams,
|
||||
private dialogRef: DialogRef<SendItemDialogResult>,
|
||||
private i18nService: I18nService,
|
||||
private sendApiService: SendApiService,
|
||||
private toastService: ToastService,
|
||||
private dialogService: DialogService,
|
||||
) {
|
||||
this.config = params.formConfig;
|
||||
this.headerText = this.getHeaderText(this.config.mode, this.config.sendType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the event when the send is created.
|
||||
*/
|
||||
async onSendCreated(send: SendView) {
|
||||
// FIXME Add dialogService.open send-created dialog
|
||||
this.dialogRef.close(SendItemDialogResult.Saved);
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the event when the send is updated.
|
||||
*/
|
||||
async onSendUpdated(send: SendView) {
|
||||
this.dialogRef.close(SendItemDialogResult.Saved);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the event when the send is deleted.
|
||||
*/
|
||||
async onSendDeleted() {
|
||||
this.dialogRef.close(SendItemDialogResult.Deleted);
|
||||
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: null,
|
||||
message: this.i18nService.t("deletedSend"),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the deletion of the current Send.
|
||||
*/
|
||||
deleteSend = async () => {
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
title: { key: "deleteSend" },
|
||||
content: { key: "deleteSendPermanentConfirmation" },
|
||||
type: "warning",
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.sendApiService.delete(this.config.originalSend?.id);
|
||||
} catch (e) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: null,
|
||||
message: e.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await this.onSendDeleted();
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the header text based on the mode and type.
|
||||
* @param mode The mode of the send form.
|
||||
* @param type The type of the send
|
||||
* @returns The header text.
|
||||
*/
|
||||
private getHeaderText(mode: SendFormMode, type: SendType) {
|
||||
const headerKey =
|
||||
mode === "edit" || mode === "partial-edit" ? "editItemHeader" : "newItemHeader";
|
||||
|
||||
switch (type) {
|
||||
case SendType.Text:
|
||||
return this.i18nService.t(headerKey, this.i18nService.t("textSend"));
|
||||
case SendType.File:
|
||||
return this.i18nService.t(headerKey, this.i18nService.t("fileSend"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the send add/edit dialog.
|
||||
* @param dialogService Instance of the DialogService.
|
||||
* @param params The parameters for the dialog.
|
||||
* @returns The dialog result.
|
||||
*/
|
||||
static open(dialogService: DialogService, params: SendItemDialogParams) {
|
||||
return dialogService.open<SendItemDialogResult, SendItemDialogParams>(
|
||||
SendAddEditDialogComponent,
|
||||
{
|
||||
data: params,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
export * from "./icons";
|
||||
export * from "./send-form";
|
||||
export { NewSendDropdownComponent } from "./new-send-dropdown/new-send-dropdown.component";
|
||||
export * from "./add-edit/send-add-edit-dialog.component";
|
||||
export { SendListItemsContainerComponent } from "./send-list-items-container/send-list-items-container.component";
|
||||
export { SendItemsService } from "./services/send-items.service";
|
||||
export { SendSearchComponent } from "./send-search/send-search.component";
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
<div class="tw-mb-2">
|
||||
<bit-search
|
||||
[placeholder]="'search' | i18n"
|
||||
[(ngModel)]="searchText"
|
||||
(ngModelChange)="onSearchTextChanged()"
|
||||
appAutofocus
|
||||
>
|
||||
</bit-search>
|
||||
</div>
|
||||
<bit-search
|
||||
[placeholder]="'search' | i18n"
|
||||
[(ngModel)]="searchText"
|
||||
(ngModelChange)="onSearchTextChanged()"
|
||||
appAutofocus
|
||||
>
|
||||
</bit-search>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
@@ -20,7 +18,7 @@ const SearchTextDebounceInterval = 200;
|
||||
templateUrl: "send-search.component.html",
|
||||
})
|
||||
export class SendSearchComponent {
|
||||
searchText: string;
|
||||
searchText: string = "";
|
||||
|
||||
private searchText$ = new Subject<string>();
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ export abstract class ChangeLoginPasswordService {
|
||||
/**
|
||||
* Attempts to find a well-known change password URL for the given cipher. Only works for Login ciphers with at
|
||||
* least one http/https URL. If no well-known change password URL is found, the first URL is returned.
|
||||
* Checks each URL until the first reliable one well-known URL is found, otherwise returns the first URL.
|
||||
*
|
||||
* Non-Login ciphers and Logins with no valid http/https URLs return null.
|
||||
*/
|
||||
abstract getChangePasswordUrl(cipher: CipherView): Promise<string | null>;
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { coerceBooleanProperty } from "@angular/cdk/coercion";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, EventEmitter, Input, Output } from "@angular/core";
|
||||
@@ -18,9 +16,6 @@ import { AlgorithmInfo, GeneratedCredential } from "@bitwarden/generator-core";
|
||||
imports: [CommonModule, GeneratorModule],
|
||||
})
|
||||
export class CipherFormGeneratorComponent {
|
||||
@Input()
|
||||
onAlgorithmSelected: (selected: AlgorithmInfo) => void;
|
||||
|
||||
@Input()
|
||||
uri: string = "";
|
||||
|
||||
@@ -28,17 +23,25 @@ export class CipherFormGeneratorComponent {
|
||||
* The type of generator form to show.
|
||||
*/
|
||||
@Input({ required: true })
|
||||
type: "password" | "username";
|
||||
type: "password" | "username" = "password";
|
||||
|
||||
/** Removes bottom margin of internal sections */
|
||||
@Input({ transform: coerceBooleanProperty }) disableMargin = false;
|
||||
|
||||
@Output()
|
||||
algorithmSelected = new EventEmitter<AlgorithmInfo>();
|
||||
|
||||
/**
|
||||
* Emits an event when a new value is generated.
|
||||
*/
|
||||
@Output()
|
||||
valueGenerated = new EventEmitter<string>();
|
||||
|
||||
/** Event handler for when an algorithm is selected */
|
||||
onAlgorithmSelected = (selected: AlgorithmInfo) => {
|
||||
this.algorithmSelected.emit(selected);
|
||||
};
|
||||
|
||||
/** Event handler for both generation components */
|
||||
onCredentialGenerated = (generatedCred: GeneratedCredential) => {
|
||||
this.valueGenerated.emit(generatedCred.credential);
|
||||
|
||||
@@ -3,6 +3,19 @@
|
||||
{{ "cardExpiredMessage" | i18n }}
|
||||
</bit-callout>
|
||||
|
||||
<ng-container *ngIf="isSecurityTasksEnabled$ | async">
|
||||
<bit-callout
|
||||
*ngIf="cipher?.login.uris.length > 0 && hadPendingChangePasswordTask"
|
||||
type="warning"
|
||||
[title]="''"
|
||||
>
|
||||
<i class="bwi bwi-exclamation-triangle tw-text-warning" aria-hidden="true"></i>
|
||||
<a bitLink (click)="launchChangePassword()">
|
||||
{{ "changeAtRiskPassword" | i18n }}
|
||||
<i class="bwi bwi-popout tw-ml-1" aria-hidden="true"></i>
|
||||
</a>
|
||||
</bit-callout>
|
||||
</ng-container>
|
||||
<!-- HELPER TEXT -->
|
||||
<p
|
||||
class="tw-text-sm tw-text-muted"
|
||||
@@ -23,7 +36,15 @@
|
||||
</app-item-details-v2>
|
||||
|
||||
<!-- LOGIN CREDENTIALS -->
|
||||
<app-login-credentials-view *ngIf="hasLogin" [cipher]="cipher"></app-login-credentials-view>
|
||||
<app-login-credentials-view
|
||||
*ngIf="hasLogin"
|
||||
[cipher]="cipher"
|
||||
[activeUserId]="activeUserId$ | async"
|
||||
[hadPendingChangePasswordTask]="
|
||||
hadPendingChangePasswordTask && (isSecurityTasksEnabled$ | async)
|
||||
"
|
||||
(handleChangePassword)="launchChangePassword()"
|
||||
></app-login-credentials-view>
|
||||
|
||||
<!-- AUTOFILL OPTIONS -->
|
||||
<app-autofill-options-view
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, Input, OnChanges, OnDestroy } from "@angular/core";
|
||||
import { firstValueFrom, map, Observable, Subject, takeUntil } from "rxjs";
|
||||
import { firstValueFrom, Observable, Subject, takeUntil } from "rxjs";
|
||||
|
||||
import { CollectionService, CollectionView } from "@bitwarden/admin-console/common";
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
@@ -12,11 +12,17 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { isCardExpired } from "@bitwarden/common/autofill/utils";
|
||||
import { CollectionId } from "@bitwarden/common/types/guid";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { CollectionId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
|
||||
import { CalloutModule, SearchModule } from "@bitwarden/components";
|
||||
import { AnchorLinkDirective, CalloutModule, SearchModule } from "@bitwarden/components";
|
||||
|
||||
import { ChangeLoginPasswordService } from "../abstractions/change-login-password.service";
|
||||
import { TaskService, SecurityTaskType } from "../tasks";
|
||||
|
||||
import { AdditionalOptionsComponent } from "./additional-options/additional-options.component";
|
||||
import { AttachmentsV2ViewComponent } from "./attachments/attachments-v2-view.component";
|
||||
@@ -48,12 +54,13 @@ import { ViewIdentitySectionsComponent } from "./view-identity-sections/view-ide
|
||||
ViewIdentitySectionsComponent,
|
||||
LoginCredentialsViewComponent,
|
||||
AutofillOptionsViewComponent,
|
||||
AnchorLinkDirective,
|
||||
],
|
||||
})
|
||||
export class CipherViewComponent implements OnChanges, OnDestroy {
|
||||
@Input({ required: true }) cipher: CipherView | null = null;
|
||||
|
||||
private activeUserId$ = this.accountService.activeAccount$.pipe(map((a) => a?.id));
|
||||
activeUserId$ = getUserId(this.accountService.activeAccount$);
|
||||
|
||||
/**
|
||||
* Optional list of collections the cipher is assigned to. If none are provided, they will be fetched using the
|
||||
@@ -68,12 +75,18 @@ export class CipherViewComponent implements OnChanges, OnDestroy {
|
||||
folder$: Observable<FolderView | undefined> | undefined;
|
||||
private destroyed$: Subject<void> = new Subject();
|
||||
cardIsExpired: boolean = false;
|
||||
hadPendingChangePasswordTask: boolean = false;
|
||||
isSecurityTasksEnabled$ = this.configService.getFeatureFlag$(FeatureFlag.SecurityTasks);
|
||||
|
||||
constructor(
|
||||
private organizationService: OrganizationService,
|
||||
private collectionService: CollectionService,
|
||||
private folderService: FolderService,
|
||||
private accountService: AccountService,
|
||||
private defaultTaskService: TaskService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
private changeLoginPasswordService: ChangeLoginPasswordService,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async ngOnChanges() {
|
||||
@@ -137,7 +150,11 @@ export class CipherViewComponent implements OnChanges, OnDestroy {
|
||||
);
|
||||
}
|
||||
|
||||
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
|
||||
const userId = await firstValueFrom(this.activeUserId$);
|
||||
|
||||
if (this.cipher.edit && this.cipher.viewPassword) {
|
||||
await this.checkPendingChangePasswordTasks(userId);
|
||||
}
|
||||
|
||||
if (this.cipher.organizationId && userId) {
|
||||
this.organization$ = this.organizationService
|
||||
@@ -147,15 +164,29 @@ export class CipherViewComponent implements OnChanges, OnDestroy {
|
||||
}
|
||||
|
||||
if (this.cipher.folderId) {
|
||||
const activeUserId = await firstValueFrom(this.activeUserId$);
|
||||
|
||||
if (!activeUserId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.folder$ = this.folderService
|
||||
.getDecrypted$(this.cipher.folderId, activeUserId)
|
||||
.getDecrypted$(this.cipher.folderId, userId)
|
||||
.pipe(takeUntil(this.destroyed$));
|
||||
}
|
||||
}
|
||||
|
||||
async checkPendingChangePasswordTasks(userId: UserId): Promise<void> {
|
||||
const tasks = await firstValueFrom(this.defaultTaskService.pendingTasks$(userId));
|
||||
|
||||
this.hadPendingChangePasswordTask = tasks?.some((task) => {
|
||||
return (
|
||||
task.cipherId === this.cipher?.id && task.type === SecurityTaskType.UpdateAtRiskCredential
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
launchChangePassword = async () => {
|
||||
if (this.cipher != null) {
|
||||
const url = await this.changeLoginPasswordService.getChangePasswordUrl(this.cipher);
|
||||
if (url == null) {
|
||||
return;
|
||||
}
|
||||
this.platformUtilsService.launchUri(url);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -89,6 +89,12 @@
|
||||
(click)="logCopyEvent()"
|
||||
></button>
|
||||
</bit-form-field>
|
||||
<bit-hint *ngIf="hadPendingChangePasswordTask">
|
||||
<a bitLink (click)="launchChangePasswordEvent()">
|
||||
{{ "changeAtRiskPassword" | i18n }}
|
||||
<i class="bwi bwi-popout tw-ml-1" aria-hidden="true"></i>
|
||||
</a>
|
||||
</bit-hint>
|
||||
<div
|
||||
*ngIf="showPasswordCount && passwordRevealed"
|
||||
[ngClass]="{ 'tw-mt-3': !cipher.login.totp, 'tw-mb-2': true }"
|
||||
|
||||
@@ -8,6 +8,7 @@ import { EventCollectionService } from "@bitwarden/common/abstractions/event/eve
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
|
||||
import { EventType } from "@bitwarden/common/enums";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
@@ -74,6 +75,7 @@ describe("LoginCredentialsViewComponent", () => {
|
||||
{ provide: PlatformUtilsService, useValue: mock<PlatformUtilsService>() },
|
||||
{ provide: ToastService, useValue: mock<ToastService>() },
|
||||
{ provide: I18nService, useValue: { t: (...keys: string[]) => keys.join(" ") } },
|
||||
{ provide: ConfigService, useValue: mock<ConfigService>() },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { CommonModule, DatePipe } from "@angular/common";
|
||||
import { Component, inject, Input } from "@angular/core";
|
||||
import { Component, EventEmitter, inject, Input, Output } from "@angular/core";
|
||||
import { Observable, switchMap } from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
@@ -10,6 +10,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
|
||||
import { EventType } from "@bitwarden/common/enums";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import {
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
SectionComponent,
|
||||
SectionHeaderComponent,
|
||||
TypographyModule,
|
||||
LinkModule,
|
||||
IconButtonModule,
|
||||
BadgeModule,
|
||||
ColorPasswordModule,
|
||||
@@ -46,10 +48,14 @@ type TotpCodeValues = {
|
||||
ColorPasswordModule,
|
||||
BitTotpCountdownComponent,
|
||||
ReadOnlyCipherCardComponent,
|
||||
LinkModule,
|
||||
],
|
||||
})
|
||||
export class LoginCredentialsViewComponent {
|
||||
@Input() cipher: CipherView;
|
||||
@Input() activeUserId: UserId;
|
||||
@Input() hadPendingChangePasswordTask: boolean;
|
||||
@Output() handleChangePassword = new EventEmitter<void>();
|
||||
|
||||
isPremium$: Observable<boolean> = this.accountService.activeAccount$.pipe(
|
||||
switchMap((account) =>
|
||||
@@ -59,6 +65,7 @@ export class LoginCredentialsViewComponent {
|
||||
showPasswordCount: boolean = false;
|
||||
passwordRevealed: boolean = false;
|
||||
totpCodeCopyObj: TotpCodeValues;
|
||||
|
||||
private datePipe = inject(DatePipe);
|
||||
|
||||
constructor(
|
||||
@@ -111,4 +118,8 @@ export class LoginCredentialsViewComponent {
|
||||
this.cipher.organizationId,
|
||||
);
|
||||
}
|
||||
|
||||
launchChangePasswordEvent(): void {
|
||||
this.handleChangePassword.emit();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,13 +131,13 @@ describe("DefaultChangeLoginPasswordService", () => {
|
||||
const cipher = {
|
||||
type: CipherType.Login,
|
||||
login: Object.assign(new LoginView(), {
|
||||
uris: [{ uri: "https://example.com" }],
|
||||
uris: [{ uri: "https://example.com/" }],
|
||||
}),
|
||||
} as CipherView;
|
||||
|
||||
const url = await service.getChangePasswordUrl(cipher);
|
||||
|
||||
expect(url).toBe("https://example.com");
|
||||
expect(url).toBe("https://example.com/");
|
||||
});
|
||||
|
||||
it("should return the original URI when the well-known URL is not found", async () => {
|
||||
@@ -146,12 +146,42 @@ describe("DefaultChangeLoginPasswordService", () => {
|
||||
const cipher = {
|
||||
type: CipherType.Login,
|
||||
login: Object.assign(new LoginView(), {
|
||||
uris: [{ uri: "https://example.com" }],
|
||||
uris: [{ uri: "https://example.com/" }],
|
||||
}),
|
||||
} as CipherView;
|
||||
|
||||
const url = await service.getChangePasswordUrl(cipher);
|
||||
|
||||
expect(url).toBe("https://example.com");
|
||||
expect(url).toBe("https://example.com/");
|
||||
});
|
||||
|
||||
it("should try the next URI if the first one fails", async () => {
|
||||
mockApiService.nativeFetch.mockImplementation((request) => {
|
||||
if (
|
||||
request.url.endsWith("resource-that-should-not-exist-whose-status-code-should-not-be-200")
|
||||
) {
|
||||
return Promise.resolve(mockShouldNotExistResponse);
|
||||
}
|
||||
|
||||
if (request.url.endsWith(".well-known/change-password")) {
|
||||
if (request.url.includes("working.com")) {
|
||||
return Promise.resolve(mockWellKnownResponse);
|
||||
}
|
||||
return Promise.resolve(new Response("Not Found", { status: 404 }));
|
||||
}
|
||||
|
||||
throw new Error("Unexpected request");
|
||||
});
|
||||
|
||||
const cipher = {
|
||||
type: CipherType.Login,
|
||||
login: Object.assign(new LoginView(), {
|
||||
uris: [{ uri: "https://no-wellknown.com/" }, { uri: "https://working.com/" }],
|
||||
}),
|
||||
} as CipherView;
|
||||
|
||||
const url = await service.getChangePasswordUrl(cipher);
|
||||
|
||||
expect(url).toBe("https://working.com/.well-known/change-password");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,25 +20,31 @@ export class DefaultChangeLoginPasswordService implements ChangeLoginPasswordSer
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find the first valid URL that is an HTTP or HTTPS URL
|
||||
const url = cipher.login.uris
|
||||
// Filter for valid URLs that are HTTP(S)
|
||||
const urls = cipher.login.uris
|
||||
.map((m) => Utils.getUrl(m.uri))
|
||||
.find((m) => m != null && (m.protocol === "http:" || m.protocol === "https:"));
|
||||
.filter((m) => m != null && (m.protocol === "http:" || m.protocol === "https:"));
|
||||
|
||||
if (url == null) {
|
||||
if (urls.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [reliable, wellKnownChangeUrl] = await Promise.all([
|
||||
this.hasReliableHttpStatusCode(url.origin),
|
||||
this.getWellKnownChangePasswordUrl(url.origin),
|
||||
]);
|
||||
for (const url of urls) {
|
||||
const [reliable, wellKnownChangeUrl] = await Promise.all([
|
||||
this.hasReliableHttpStatusCode(url.origin),
|
||||
this.getWellKnownChangePasswordUrl(url.origin),
|
||||
]);
|
||||
|
||||
if (!reliable || wellKnownChangeUrl == null) {
|
||||
return cipher.login.uri;
|
||||
// Some servers return a 200 OK for a resource that should not exist
|
||||
// Which means we cannot trust the well-known URL is valid, so we skip it
|
||||
// to avoid potentially sending users to a 404 page
|
||||
if (reliable && wellKnownChangeUrl != null) {
|
||||
return wellKnownChangeUrl;
|
||||
}
|
||||
}
|
||||
|
||||
return wellKnownChangeUrl;
|
||||
// No reliable well-known URL found, fallback to the first URL
|
||||
return urls[0].href;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user