1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-22 03:03:43 +00:00

Merge branch 'main' of https://github.com/bitwarden/clients into vault/pm-18707/desktop-sync-issues

This commit is contained in:
Nick Krantz
2025-03-19 16:12:06 -05:00
777 changed files with 27348 additions and 27039 deletions

View File

@@ -38,7 +38,6 @@ import {
ProviderUserUserDetailsResponse,
} from "../admin-console/models/response/provider/provider-user.response";
import { SelectionReadOnlyResponse } from "../admin-console/models/response/selection-read-only.response";
import { AuthRequest } from "../auth/models/request/auth.request";
import { DeviceVerificationRequest } from "../auth/models/request/device-verification.request";
import { DisableTwoFactorAuthenticatorRequest } from "../auth/models/request/disable-two-factor-authenticator.request";
import { EmailTokenRequest } from "../auth/models/request/email-token.request";
@@ -47,19 +46,12 @@ import { PasswordTokenRequest } from "../auth/models/request/identity-token/pass
import { SsoTokenRequest } from "../auth/models/request/identity-token/sso-token.request";
import { UserApiTokenRequest } from "../auth/models/request/identity-token/user-api-token.request";
import { WebAuthnLoginTokenRequest } from "../auth/models/request/identity-token/webauthn-login-token.request";
import { KeyConnectorUserKeyRequest } from "../auth/models/request/key-connector-user-key.request";
import { PasswordHintRequest } from "../auth/models/request/password-hint.request";
import { PasswordRequest } from "../auth/models/request/password.request";
import { PasswordlessAuthRequest } from "../auth/models/request/passwordless-auth.request";
import { SecretVerificationRequest } from "../auth/models/request/secret-verification.request";
import { SetKeyConnectorKeyRequest } from "../auth/models/request/set-key-connector-key.request";
import { SetPasswordRequest } from "../auth/models/request/set-password.request";
import { TwoFactorEmailRequest } from "../auth/models/request/two-factor-email.request";
import { TwoFactorProviderRequest } from "../auth/models/request/two-factor-provider.request";
import { TwoFactorRecoveryRequest } from "../auth/models/request/two-factor-recovery.request";
import { UpdateProfileRequest } from "../auth/models/request/update-profile.request";
import { UpdateTdeOffboardingPasswordRequest } from "../auth/models/request/update-tde-offboarding-password.request";
import { UpdateTempPasswordRequest } from "../auth/models/request/update-temp-password.request";
import { UpdateTwoFactorAuthenticatorRequest } from "../auth/models/request/update-two-factor-authenticator.request";
import { UpdateTwoFactorDuoRequest } from "../auth/models/request/update-two-factor-duo.request";
import { UpdateTwoFactorEmailRequest } from "../auth/models/request/update-two-factor-email.request";
@@ -96,6 +88,8 @@ import { PaymentResponse } from "../billing/models/response/payment.response";
import { PlanResponse } from "../billing/models/response/plan.response";
import { SubscriptionResponse } from "../billing/models/response/subscription.response";
import { TaxInfoResponse } from "../billing/models/response/tax-info.response";
import { KeyConnectorUserKeyRequest } from "../key-management/key-connector/models/key-connector-user-key.request";
import { SetKeyConnectorKeyRequest } from "../key-management/key-connector/models/set-key-connector-key.request";
import { DeleteRecoverRequest } from "../models/request/delete-recover.request";
import { EventRequest } from "../models/request/event.request";
import { KdfRequest } from "../models/request/kdf.request";
@@ -169,8 +163,6 @@ export abstract class ApiService {
postPrelogin: (request: PreloginRequest) => Promise<PreloginResponse>;
postEmailToken: (request: EmailTokenRequest) => Promise<any>;
postEmail: (request: EmailRequest) => Promise<any>;
postPassword: (request: PasswordRequest) => Promise<any>;
setPassword: (request: SetPasswordRequest) => Promise<any>;
postSetKeyConnectorKey: (request: SetKeyConnectorKeyRequest) => Promise<any>;
postSecurityStamp: (request: SecretVerificationRequest) => Promise<any>;
getAccountRevisionDate: () => Promise<number>;
@@ -189,13 +181,8 @@ export abstract class ApiService {
postAccountKdf: (request: KdfRequest) => Promise<any>;
postUserApiKey: (id: string, request: SecretVerificationRequest) => Promise<ApiKeyResponse>;
postUserRotateApiKey: (id: string, request: SecretVerificationRequest) => Promise<ApiKeyResponse>;
putUpdateTempPassword: (request: UpdateTempPasswordRequest) => Promise<any>;
putUpdateTdeOffboardingPassword: (request: UpdateTdeOffboardingPasswordRequest) => Promise<any>;
postConvertToKeyConnector: () => Promise<void>;
//passwordless
postAuthRequest: (request: AuthRequest) => Promise<AuthRequestResponse>;
postAdminAuthRequest: (request: AuthRequest) => Promise<AuthRequestResponse>;
getAuthResponse: (id: string, accessCode: string) => Promise<AuthRequestResponse>;
getAuthRequest: (id: string) => Promise<AuthRequestResponse>;
putAuthRequest: (id: string, request: PasswordlessAuthRequest) => Promise<AuthRequestResponse>;
getAuthRequests: () => Promise<ListResponse<AuthRequestResponse>>;
@@ -356,7 +343,6 @@ export abstract class ApiService {
organizationId: string,
request: TwoFactorProviderRequest,
) => Promise<TwoFactorProviderResponse>;
postTwoFactorRecover: (request: TwoFactorRecoveryRequest) => Promise<any>;
postTwoFactorEmailSetup: (request: TwoFactorEmailRequest) => Promise<any>;
postTwoFactorEmail: (request: TwoFactorEmailRequest) => Promise<any>;
getDeviceVerificationSettings: () => Promise<DeviceVerificationResponse>;

View File

@@ -3,16 +3,21 @@
import { Observable } from "rxjs";
import { SendView } from "../tools/send/models/view/send.view";
import { IndexedEntityId } from "../types/guid";
import { IndexedEntityId, UserId } from "../types/guid";
import { CipherView } from "../vault/models/view/cipher.view";
export abstract class SearchService {
indexedEntityId$: Observable<IndexedEntityId | null>;
indexedEntityId$: (userId: UserId) => Observable<IndexedEntityId | null>;
clearIndex: () => Promise<void>;
isSearchable: (query: string) => Promise<boolean>;
indexCiphers: (ciphersToIndex: CipherView[], indexedEntityGuid?: string) => Promise<void>;
clearIndex: (userId: UserId) => Promise<void>;
isSearchable: (userId: UserId, query: string) => Promise<boolean>;
indexCiphers: (
userId: UserId,
ciphersToIndex: CipherView[],
indexedEntityGuid?: string,
) => Promise<void>;
searchCiphers: (
userId: UserId,
query: string,
filter?: ((cipher: CipherView) => boolean) | ((cipher: CipherView) => boolean)[],
ciphers?: CipherView[],

View File

@@ -0,0 +1,28 @@
import { PasswordRequest } from "../models/request/password.request";
import { SetPasswordRequest } from "../models/request/set-password.request";
import { UpdateTdeOffboardingPasswordRequest } from "../models/request/update-tde-offboarding-password.request";
import { UpdateTempPasswordRequest } from "../models/request/update-temp-password.request";
export abstract class MasterPasswordApiService {
/**
* POSTs a SetPasswordRequest to "/accounts/set-password"
*/
abstract setPassword: (request: SetPasswordRequest) => Promise<any>;
/**
* POSTs a PasswordRequest to "/accounts/password"
*/
abstract postPassword: (request: PasswordRequest) => Promise<any>;
/**
* PUTs an UpdateTempPasswordRequest to "/accounts/update-temp-password"
*/
abstract putUpdateTempPassword: (request: UpdateTempPasswordRequest) => Promise<any>;
/**
* PUTs an UpdateTdeOffboardingPasswordRequest to "/accounts/update-tde-offboarding-password"
*/
abstract putUpdateTdeOffboardingPassword: (
request: UpdateTdeOffboardingPasswordRequest,
) => Promise<any>;
}

View File

@@ -14,7 +14,6 @@ export abstract class TokenRequest {
this.device = device != null ? device : null;
}
// eslint-disable-next-line
alterIdentityTokenHeaders(headers: Headers) {
// Implemented in subclass if required
}

View File

@@ -1,8 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { SecretVerificationRequest } from "./secret-verification.request";
export class TwoFactorRecoveryRequest extends SecretVerificationRequest {
recoveryCode: string;
email: string;
}

View File

@@ -10,6 +10,7 @@ export class AuthRequestResponse extends BaseResponse {
requestDeviceTypeValue: DeviceType;
requestDeviceIdentifier: string;
requestIpAddress: string;
requestCountryName: string;
key: string; // could be either an encrypted MasterKey or an encrypted UserKey
masterPasswordHash: string; // if hash is present, the `key` above is an encrypted MasterKey (else `key` is an encrypted UserKey)
creationDate: string;
@@ -26,6 +27,7 @@ export class AuthRequestResponse extends BaseResponse {
this.requestDeviceTypeValue = this.getResponseProperty("RequestDeviceTypeValue");
this.requestDeviceIdentifier = this.getResponseProperty("RequestDeviceIdentifier");
this.requestIpAddress = this.getResponseProperty("RequestIpAddress");
this.requestCountryName = this.getResponseProperty("RequestCountryName");
this.key = this.getResponseProperty("Key");
this.masterPasswordHash = this.getResponseProperty("MasterPasswordHash");
this.creationDate = this.getResponseProperty("CreationDate");

View File

@@ -0,0 +1,17 @@
import { Jsonify } from "type-fest";
import { AuthRequest } from "@bitwarden/common/auth/models/request/auth.request";
import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
import { View } from "@bitwarden/common/models/view/view";
export class LoginViaAuthRequestView implements View {
authRequest: AuthRequest | undefined = undefined;
authRequestResponse: AuthRequestResponse | undefined = undefined;
fingerprintPhrase: string | undefined = undefined;
privateKey: string | undefined = undefined;
publicKey: string | undefined = undefined;
static fromJSON(obj: Partial<Jsonify<LoginViaAuthRequestView>>): LoginViaAuthRequestView {
return Object.assign(new LoginViaAuthRequestView(), obj);
}
}

View File

@@ -0,0 +1,85 @@
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MasterPasswordApiService as MasterPasswordApiServiceAbstraction } from "../../abstractions/master-password-api.service.abstraction";
import { PasswordRequest } from "../../models/request/password.request";
import { SetPasswordRequest } from "../../models/request/set-password.request";
import { UpdateTdeOffboardingPasswordRequest } from "../../models/request/update-tde-offboarding-password.request";
import { UpdateTempPasswordRequest } from "../../models/request/update-temp-password.request";
export class MasterPasswordApiService implements MasterPasswordApiServiceAbstraction {
constructor(
private apiService: ApiService,
private logService: LogService,
) {}
async setPassword(request: SetPasswordRequest): Promise<any> {
try {
const response = await this.apiService.send(
"POST",
"/accounts/set-password",
request,
true,
false,
);
return response;
} catch (e: unknown) {
this.logService.error(e);
throw e;
}
}
async postPassword(request: PasswordRequest): Promise<any> {
try {
const response = await this.apiService.send(
"POST",
"/accounts/password",
request,
true,
false,
);
return response;
} catch (e: unknown) {
this.logService.error(e);
throw e;
}
}
async putUpdateTempPassword(request: UpdateTempPasswordRequest): Promise<any> {
try {
const response = await this.apiService.send(
"PUT",
"/accounts/update-temp-password",
request,
true,
false,
);
return response;
} catch (e: unknown) {
this.logService.error(e);
throw e;
}
}
async putUpdateTdeOffboardingPassword(
request: UpdateTdeOffboardingPasswordRequest,
): Promise<any> {
try {
const response = await this.apiService.send(
"PUT",
"/accounts/update-tde-offboarding-password",
request,
true,
false,
);
return response;
} catch (e: unknown) {
this.logService.error(e);
throw e;
}
}
}

View File

@@ -0,0 +1,130 @@
import { mock, MockProxy } from "jest-mock-extended";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { KdfType } from "@bitwarden/key-management";
import { PasswordRequest } from "../../models/request/password.request";
import { SetPasswordRequest } from "../../models/request/set-password.request";
import { UpdateTdeOffboardingPasswordRequest } from "../../models/request/update-tde-offboarding-password.request";
import { UpdateTempPasswordRequest } from "../../models/request/update-temp-password.request";
import { MasterPasswordApiService } from "./master-password-api.service.implementation";
describe("MasterPasswordApiService", () => {
let apiService: MockProxy<ApiService>;
let logService: MockProxy<LogService>;
let sut: MasterPasswordApiService;
beforeEach(() => {
apiService = mock<ApiService>();
logService = mock<LogService>();
sut = new MasterPasswordApiService(apiService, logService);
});
it("should instantiate", () => {
expect(sut).not.toBeFalsy();
});
describe("setPassword", () => {
it("should call apiService.send with the correct parameters", async () => {
// Arrange
const request = new SetPasswordRequest(
"masterPasswordHash",
"key",
"masterPasswordHint",
"orgIdentifier",
{
publicKey: "publicKey",
encryptedPrivateKey: "encryptedPrivateKey",
},
KdfType.PBKDF2_SHA256,
600_000,
);
// Act
await sut.setPassword(request);
// Assert
expect(apiService.send).toHaveBeenCalledWith(
"POST",
"/accounts/set-password",
request,
true,
false,
);
});
});
describe("postPassword", () => {
it("should call apiService.send with the correct parameters", async () => {
// Arrange
const request = {
newMasterPasswordHash: "newMasterPasswordHash",
masterPasswordHint: "masterPasswordHint",
key: "key",
masterPasswordHash: "masterPasswordHash",
} as PasswordRequest;
// Act
await sut.postPassword(request);
// Assert
expect(apiService.send).toHaveBeenCalledWith(
"POST",
"/accounts/password",
request,
true,
false,
);
});
});
describe("putUpdateTempPassword", () => {
it("should call apiService.send with the correct parameters", async () => {
// Arrange
const request = {
masterPasswordHint: "masterPasswordHint",
newMasterPasswordHash: "newMasterPasswordHash",
key: "key",
} as UpdateTempPasswordRequest;
// Act
await sut.putUpdateTempPassword(request);
// Assert
expect(apiService.send).toHaveBeenCalledWith(
"PUT",
"/accounts/update-temp-password",
request,
true,
false,
);
});
});
describe("putUpdateTdeOffboardingPassword", () => {
it("should call apiService.send with the correct parameters", async () => {
// Arrange
const request = {
masterPasswordHint: "masterPasswordHint",
newMasterPasswordHash: "newMasterPasswordHash",
key: "key",
} as UpdateTdeOffboardingPasswordRequest;
// Act
await sut.putUpdateTdeOffboardingPassword(request);
// Assert
expect(apiService.send).toHaveBeenCalledWith(
"PUT",
"/accounts/update-tde-offboarding-password",
request,
true,
false,
);
});
});
});

View File

@@ -16,13 +16,13 @@ import {
} from "@bitwarden/key-management";
import { FakeAccountService, mockAccountServiceWith } from "../../../../spec";
import { InternalMasterPasswordServiceAbstraction } from "../../../key-management/master-password/abstractions/master-password.service.abstraction";
import { VaultTimeoutSettingsService } from "../../../key-management/vault-timeout";
import { I18nService } from "../../../platform/abstractions/i18n.service";
import { HashPurpose } from "../../../platform/enums";
import { Utils } from "../../../platform/misc/utils";
import { UserId } from "../../../types/guid";
import { MasterKey } from "../../../types/key";
import { InternalMasterPasswordServiceAbstraction } from "../../abstractions/master-password.service.abstraction";
import { UserVerificationApiServiceAbstraction } from "../../abstractions/user-verification/user-verification-api.service.abstraction";
import { VerificationType } from "../../enums/verification-type";
import { MasterPasswordPolicyResponse } from "../../models/response/master-password-policy.response";

View File

@@ -13,11 +13,11 @@ import {
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { PinServiceAbstraction } from "../../../../../auth/src/common/abstractions/pin.service.abstraction";
import { InternalMasterPasswordServiceAbstraction } from "../../../key-management/master-password/abstractions/master-password.service.abstraction";
import { I18nService } from "../../../platform/abstractions/i18n.service";
import { HashPurpose } from "../../../platform/enums";
import { UserId } from "../../../types/guid";
import { AccountService } from "../../abstractions/account.service";
import { InternalMasterPasswordServiceAbstraction } from "../../abstractions/master-password.service.abstraction";
import { UserVerificationApiServiceAbstraction } from "../../abstractions/user-verification/user-verification-api.service.abstraction";
import { UserVerificationService as UserVerificationServiceAbstraction } from "../../abstractions/user-verification/user-verification.service.abstraction";
import { VerificationType } from "../../enums/verification-type";

View File

@@ -1,6 +1,8 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { TaxInfoResponse } from "@bitwarden/common/billing/models/response/tax-info.response";
import { OrganizationCreateRequest } from "../../admin-console/models/request/organization-create.request";
import { ProviderOrganizationOrganizationDetailsResponse } from "../../admin-console/models/response/provider/provider-organization.response";
import { SubscriptionCancellationRequest } from "../../billing/models/request/subscription-cancellation.request";
@@ -50,6 +52,8 @@ export abstract class BillingApiServiceAbstraction {
getProviderSubscription: (providerId: string) => Promise<ProviderSubscriptionResponse>;
getProviderTaxInformation: (providerId: string) => Promise<TaxInfoResponse>;
updateOrganizationPaymentMethod: (
organizationId: string,
request: UpdatePaymentMethodRequest,
@@ -66,6 +70,11 @@ export abstract class BillingApiServiceAbstraction {
request: UpdateClientOrganizationRequest,
) => Promise<any>;
updateProviderPaymentMethod: (
providerId: string,
request: UpdatePaymentMethodRequest,
) => Promise<void>;
updateProviderTaxInformation: (
providerId: string,
request: ExpandedTaxInfoUpdateRequest,
@@ -76,6 +85,11 @@ export abstract class BillingApiServiceAbstraction {
request: VerifyBankAccountRequest,
) => Promise<void>;
verifyProviderBankAccount: (
providerId: string,
request: VerifyBankAccountRequest,
) => Promise<void>;
restartSubscription: (
organizationId: string,
request: OrganizationCreateRequest,

View File

@@ -1,3 +1,5 @@
import { PaymentSourceResponse } from "@bitwarden/common/billing/models/response/payment-source.response";
import { ProviderType } from "../../../admin-console/enums";
import { BaseResponse } from "../../../models/response/base.response";
import { PlanType, ProductTierType } from "../../enums";
@@ -16,6 +18,7 @@ export class ProviderSubscriptionResponse extends BaseResponse {
cancelAt?: string;
suspension?: SubscriptionSuspensionResponse;
providerType: ProviderType;
paymentSource?: PaymentSourceResponse;
constructor(response: any) {
super(response);
@@ -38,6 +41,10 @@ export class ProviderSubscriptionResponse extends BaseResponse {
this.suspension = new SubscriptionSuspensionResponse(suspension);
}
this.providerType = this.getResponseProperty("providerType");
const paymentSource = this.getResponseProperty("paymentSource");
if (paymentSource != null) {
this.paymentSource = new PaymentSourceResponse(paymentSource);
}
}
}

View File

@@ -1,13 +1,12 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { ToastService } from "@bitwarden/components";
import { TaxInfoResponse } from "@bitwarden/common/billing/models/response/tax-info.response";
import { ApiService } from "../../abstractions/api.service";
import { OrganizationCreateRequest } from "../../admin-console/models/request/organization-create.request";
import { ProviderOrganizationOrganizationDetailsResponse } from "../../admin-console/models/response/provider/provider-organization.response";
import { ErrorResponse } from "../../models/response/error.response";
import { ListResponse } from "../../models/response/list.response";
import { LogService } from "../../platform/abstractions/log.service";
import { BillingApiServiceAbstraction } from "../abstractions";
import { PaymentMethodType } from "../enums";
import { CreateClientOrganizationRequest } from "../models/request/create-client-organization.request";
@@ -23,11 +22,7 @@ import { PlanResponse } from "../models/response/plan.response";
import { ProviderSubscriptionResponse } from "../models/response/provider-subscription-response";
export class BillingApiService implements BillingApiServiceAbstraction {
constructor(
private apiService: ApiService,
private logService: LogService,
private toastService: ToastService,
) {}
constructor(private apiService: ApiService) {}
cancelOrganizationSubscription(
organizationId: string,
@@ -89,14 +84,12 @@ export class BillingApiService implements BillingApiServiceAbstraction {
}
async getOrganizationPaymentMethod(organizationId: string): Promise<PaymentMethodResponse> {
const response = await this.execute(() =>
this.apiService.send(
"GET",
"/organizations/" + organizationId + "/billing/payment-method",
null,
true,
true,
),
const response = await this.apiService.send(
"GET",
"/organizations/" + organizationId + "/billing/payment-method",
null,
true,
true,
);
return new PaymentMethodResponse(response);
}
@@ -120,38 +113,49 @@ export class BillingApiService implements BillingApiServiceAbstraction {
async getProviderClientOrganizations(
providerId: string,
): Promise<ListResponse<ProviderOrganizationOrganizationDetailsResponse>> {
const response = await this.execute(() =>
this.apiService.send("GET", "/providers/" + providerId + "/organizations", null, true, true),
const response = await this.apiService.send(
"GET",
"/providers/" + providerId + "/organizations",
null,
true,
true,
);
return new ListResponse(response, ProviderOrganizationOrganizationDetailsResponse);
}
async getProviderInvoices(providerId: string): Promise<InvoicesResponse> {
const response = await this.execute(() =>
this.apiService.send(
"GET",
"/providers/" + providerId + "/billing/invoices",
null,
true,
true,
),
const response = await this.apiService.send(
"GET",
"/providers/" + providerId + "/billing/invoices",
null,
true,
true,
);
return new InvoicesResponse(response);
}
async getProviderSubscription(providerId: string): Promise<ProviderSubscriptionResponse> {
const response = await this.execute(() =>
this.apiService.send(
"GET",
"/providers/" + providerId + "/billing/subscription",
null,
true,
true,
),
const response = await this.apiService.send(
"GET",
"/providers/" + providerId + "/billing/subscription",
null,
true,
true,
);
return new ProviderSubscriptionResponse(response);
}
async getProviderTaxInformation(providerId: string): Promise<TaxInfoResponse> {
const response = await this.apiService.send(
"GET",
"/providers/" + providerId + "/billing/tax-information",
null,
true,
true,
);
return new TaxInfoResponse(response);
}
async updateOrganizationPaymentMethod(
organizationId: string,
request: UpdatePaymentMethodRequest,
@@ -192,6 +196,19 @@ export class BillingApiService implements BillingApiServiceAbstraction {
);
}
async updateProviderPaymentMethod(
providerId: string,
request: UpdatePaymentMethodRequest,
): Promise<void> {
return await this.apiService.send(
"PUT",
"/providers/" + providerId + "/billing/payment-method",
request,
true,
false,
);
}
async updateProviderTaxInformation(providerId: string, request: ExpandedTaxInfoUpdateRequest) {
return await this.apiService.send(
"PUT",
@@ -215,6 +232,19 @@ export class BillingApiService implements BillingApiServiceAbstraction {
);
}
async verifyProviderBankAccount(
providerId: string,
request: VerifyBankAccountRequest,
): Promise<void> {
return await this.apiService.send(
"POST",
"/providers/" + providerId + "/billing/payment-method/verify-bank-account",
request,
true,
false,
);
}
async restartSubscription(
organizationId: string,
request: OrganizationCreateRequest,
@@ -227,20 +257,4 @@ export class BillingApiService implements BillingApiServiceAbstraction {
false,
);
}
private async execute(request: () => Promise<any>): Promise<any> {
try {
return await request();
} catch (error) {
this.logService.error(error);
if (error instanceof ErrorResponse) {
this.toastService.showToast({
variant: "error",
title: null,
message: error.getSingleMessage(),
});
}
throw error;
}
}
}

View File

@@ -5,7 +5,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",
@@ -18,7 +17,6 @@ export enum FeatureFlag {
IdpAutoSubmitLogin = "idp-auto-submit-login",
InlineMenuFieldQualification = "inline-menu-field-qualification",
InlineMenuPositioningImprovements = "inline-menu-positioning-improvements",
InlineMenuTotp = "inline-menu-totp",
NotificationBarAddLoginImprovements = "notification-bar-add-login-improvements",
NotificationRefresh = "notification-refresh",
UseTreeWalkerApiForPageDetailsCollection = "use-tree-walker-api-for-page-details-collection",
@@ -27,27 +25,31 @@ export enum FeatureFlag {
ItemShare = "item-share",
CriticalApps = "pm-14466-risk-insights-critical-application",
EnableRiskInsightsNotifications = "enable-risk-insights-notifications",
DesktopSendUIRefresh = "desktop-send-ui-refresh",
ExportAttachments = "export-attachments",
AC1795_UpdatedSubscriptionStatusSection = "AC-1795_updated-subscription-status-section",
ExtensionRefresh = "extension-refresh",
PM4154_BulkEncryptionService = "PM-4154-bulk-encryption-service",
VaultBulkManagementAction = "vault-bulk-management-action",
UnauthenticatedExtensionUIRefresh = "unauth-ui-refresh",
SSHKeyVaultItem = "ssh-key-vault-item",
SSHAgent = "ssh-agent",
CipherKeyEncryption = "cipher-key-encryption",
TrialPaymentOptional = "PM-8163-trial-payment",
SecurityTasks = "security-tasks",
/* Vault */
PM8851_BrowserOnboardingNudge = "pm-8851-browser-onboarding-nudge",
PM9111ExtensionPersistAddEditForm = "pm-9111-extension-persist-add-edit-form",
NewDeviceVerificationTemporaryDismiss = "new-device-temporary-dismiss",
NewDeviceVerificationPermanentDismiss = "new-device-permanent-dismiss",
VaultBulkManagementAction = "vault-bulk-management-action",
SecurityTasks = "security-tasks",
/* Auth */
PM9112_DeviceApprovalPersistence = "pm-9112-device-approval-persistence",
PM4154_BulkEncryptionService = "PM-4154-bulk-encryption-service",
UnauthenticatedExtensionUIRefresh = "unauth-ui-refresh",
CipherKeyEncryption = "cipher-key-encryption",
TrialPaymentOptional = "PM-8163-trial-payment",
MacOsNativeCredentialSync = "macos-native-credential-sync",
PM9111ExtensionPersistAddEditForm = "pm-9111-extension-persist-add-edit-form",
PrivateKeyRegeneration = "pm-12241-private-key-regeneration",
ResellerManagedOrgAlert = "PM-15814-alert-owners-of-reseller-managed-orgs",
AccountDeprovisioningBanner = "pm-17120-account-deprovisioning-admin-console-banner",
PM15179_AddExistingOrgsFromProviderPortal = "pm-15179-add-existing-orgs-from-provider-portal",
RecoveryCodeLogin = "pm-17128-recovery-code-login",
PM12276_BreadcrumbEventLogs = "pm-12276-breadcrumbing-for-business-features",
PM18794_ProviderPaymentMethod = "pm-18794-provider-payment-method",
}
export type AllowedFeatureFlagTypes = boolean | number | string;
@@ -63,7 +65,6 @@ const FALSE = false as boolean;
*/
export const DefaultFeatureFlagValue = {
/* Admin Console Team */
[FeatureFlag.ProviderClientVaultPrivacyBanner]: FALSE,
[FeatureFlag.AccountDeprovisioning]: FALSE,
[FeatureFlag.VerifiedSsoDomainEndpoint]: FALSE,
[FeatureFlag.LimitItemDeletion]: FALSE,
@@ -76,7 +77,6 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.IdpAutoSubmitLogin]: FALSE,
[FeatureFlag.InlineMenuFieldQualification]: FALSE,
[FeatureFlag.InlineMenuPositioningImprovements]: FALSE,
[FeatureFlag.InlineMenuTotp]: FALSE,
[FeatureFlag.NotificationBarAddLoginImprovements]: FALSE,
[FeatureFlag.NotificationRefresh]: FALSE,
[FeatureFlag.UseTreeWalkerApiForPageDetailsCollection]: FALSE,
@@ -85,27 +85,31 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.ItemShare]: FALSE,
[FeatureFlag.CriticalApps]: FALSE,
[FeatureFlag.EnableRiskInsightsNotifications]: FALSE,
[FeatureFlag.DesktopSendUIRefresh]: FALSE,
[FeatureFlag.ExportAttachments]: FALSE,
[FeatureFlag.AC1795_UpdatedSubscriptionStatusSection]: FALSE,
[FeatureFlag.ExtensionRefresh]: FALSE,
[FeatureFlag.PM4154_BulkEncryptionService]: FALSE,
[FeatureFlag.VaultBulkManagementAction]: FALSE,
[FeatureFlag.UnauthenticatedExtensionUIRefresh]: FALSE,
[FeatureFlag.SSHKeyVaultItem]: FALSE,
[FeatureFlag.SSHAgent]: FALSE,
[FeatureFlag.CipherKeyEncryption]: FALSE,
[FeatureFlag.TrialPaymentOptional]: FALSE,
[FeatureFlag.SecurityTasks]: FALSE,
/* Vault */
[FeatureFlag.PM8851_BrowserOnboardingNudge]: FALSE,
[FeatureFlag.PM9111ExtensionPersistAddEditForm]: FALSE,
[FeatureFlag.NewDeviceVerificationTemporaryDismiss]: FALSE,
[FeatureFlag.NewDeviceVerificationPermanentDismiss]: FALSE,
[FeatureFlag.VaultBulkManagementAction]: FALSE,
[FeatureFlag.SecurityTasks]: FALSE,
/* Auth */
[FeatureFlag.PM9112_DeviceApprovalPersistence]: FALSE,
[FeatureFlag.PM4154_BulkEncryptionService]: FALSE,
[FeatureFlag.UnauthenticatedExtensionUIRefresh]: FALSE,
[FeatureFlag.CipherKeyEncryption]: FALSE,
[FeatureFlag.TrialPaymentOptional]: FALSE,
[FeatureFlag.MacOsNativeCredentialSync]: FALSE,
[FeatureFlag.PM9111ExtensionPersistAddEditForm]: FALSE,
[FeatureFlag.PrivateKeyRegeneration]: FALSE,
[FeatureFlag.ResellerManagedOrgAlert]: FALSE,
[FeatureFlag.AccountDeprovisioningBanner]: FALSE,
[FeatureFlag.PM15179_AddExistingOrgsFromProviderPortal]: FALSE,
[FeatureFlag.RecoveryCodeLogin]: FALSE,
[FeatureFlag.PM12276_BreadcrumbEventLogs]: FALSE,
[FeatureFlag.PM18794_ProviderPaymentMethod]: FALSE,
} satisfies Record<FeatureFlag, AllowedFeatureFlagTypes>;
export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;

View File

@@ -36,7 +36,6 @@ export abstract class EncryptService {
): Promise<Uint8Array | null>;
abstract rsaEncrypt(data: Uint8Array, publicKey: Uint8Array): Promise<EncString>;
abstract rsaDecrypt(data: EncString, privateKey: Uint8Array): Promise<Uint8Array>;
abstract resolveLegacyKey(key: SymmetricCryptoKey, encThing: Encrypted): SymmetricCryptoKey;
/**
* @deprecated Replaced by BulkEncryptService, remove once the feature is tested and the featureflag PM-4154-multi-worker-encryption-service is removed
* @param items The items to decrypt

View File

@@ -78,8 +78,6 @@ export class EncryptServiceImplementation implements EncryptService {
throw new Error("No key provided for decryption.");
}
key = this.resolveLegacyKey(key, encString);
// DO NOT REMOVE OR MOVE. This prevents downgrade to mac-less CBC, which would compromise integrity and confidentiality.
if (key.macKey != null && encString?.mac == null) {
this.logService.error(
@@ -145,8 +143,6 @@ export class EncryptServiceImplementation implements EncryptService {
throw new Error("Nothing provided for decryption.");
}
key = this.resolveLegacyKey(key, encThing);
// DO NOT REMOVE OR MOVE. This prevents downgrade to mac-less CBC, which would compromise integrity and confidentiality.
if (key.macKey != null && encThing.macBytes == null) {
this.logService.error(
@@ -298,19 +294,4 @@ export class EncryptServiceImplementation implements EncryptService {
this.logService.error(msg);
}
}
/**
* Transform into new key for the old encrypt-then-mac scheme if required, otherwise return the current key unchanged
* @param encThing The encrypted object (e.g. encString or encArrayBuffer) that you want to decrypt
*/
resolveLegacyKey(key: SymmetricCryptoKey, encThing: Encrypted): SymmetricCryptoKey {
if (
encThing.encryptionType === EncryptionType.AesCbc128_HmacSha256_B64 &&
key.encType === EncryptionType.AesCbc256_B64
) {
return new SymmetricCryptoKey(key.key, EncryptionType.AesCbc128_HmacSha256_B64);
}
return key;
}
}

View File

@@ -325,6 +325,25 @@ describe("EncryptService", () => {
});
});
describe("decryptToUtf8", () => {
it("throws if no key is provided", () => {
return expect(encryptService.decryptToUtf8(null, null)).rejects.toThrow(
"No key provided for decryption.",
);
});
it("returns null if key is mac key but encstring has no mac", async () => {
const key = new SymmetricCryptoKey(
makeStaticByteArray(64, 0),
EncryptionType.AesCbc256_HmacSha256_B64,
);
const encString = new EncString(EncryptionType.AesCbc256_B64, "data");
const actual = await encryptService.decryptToUtf8(encString, key);
expect(actual).toBeNull();
expect(logService.error).toHaveBeenCalled();
});
});
describe("rsa", () => {
const data = makeStaticByteArray(10, 100);
const encryptedData = makeStaticByteArray(10, 150);
@@ -370,17 +389,16 @@ describe("EncryptService", () => {
return expect(encryptService.rsaDecrypt(encString, null)).rejects.toThrow("No private key");
});
it.each([
EncryptionType.AesCbc256_B64,
EncryptionType.AesCbc128_HmacSha256_B64,
EncryptionType.AesCbc256_HmacSha256_B64,
])("throws if encryption type is %s", async (encType) => {
encString.encryptionType = encType;
it.each([EncryptionType.AesCbc256_B64, EncryptionType.AesCbc256_HmacSha256_B64])(
"throws if encryption type is %s",
async (encType) => {
encString.encryptionType = encType;
await expect(encryptService.rsaDecrypt(encString, privateKey)).rejects.toThrow(
"Invalid encryption type",
);
});
await expect(encryptService.rsaDecrypt(encString, privateKey)).rejects.toThrow(
"Invalid encryption type",
);
},
);
it("decrypts data with provided key", async () => {
cryptoFunctionService.rsaDecrypt.mockResolvedValue(data);
@@ -398,30 +416,6 @@ describe("EncryptService", () => {
});
});
describe("resolveLegacyKey", () => {
it("creates a legacy key if required", async () => {
const key = new SymmetricCryptoKey(makeStaticByteArray(32), EncryptionType.AesCbc256_B64);
const encString = mock<EncString>();
encString.encryptionType = EncryptionType.AesCbc128_HmacSha256_B64;
const actual = encryptService.resolveLegacyKey(key, encString);
const expected = new SymmetricCryptoKey(key.key, EncryptionType.AesCbc128_HmacSha256_B64);
expect(actual).toEqual(expected);
});
it("does not create a legacy key if not required", async () => {
const encType = EncryptionType.AesCbc256_HmacSha256_B64;
const key = new SymmetricCryptoKey(makeStaticByteArray(64), encType);
const encString = mock<EncString>();
encString.encryptionType = encType;
const actual = encryptService.resolveLegacyKey(key, encString);
expect(actual).toEqual(key);
});
});
describe("hash", () => {
it("hashes a string and returns b64", async () => {
cryptoFunctionService.hash.mockResolvedValue(Uint8Array.from([1, 2, 3]));

View File

@@ -2,11 +2,10 @@
// @ts-strict-ignore
import { Observable } from "rxjs";
import { EncString } from "../../platform/models/domain/enc-string";
import { UserId } from "../../types/guid";
import { DeviceKey, UserKey } from "../../types/key";
import { DeviceResponse } from "./devices/responses/device.response";
import { DeviceResponse } from "../../../auth/abstractions/devices/responses/device.response";
import { EncString } from "../../../platform/models/domain/enc-string";
import { UserId } from "../../../types/guid";
import { DeviceKey, UserKey } from "../../../types/key";
export abstract class DeviceTrustServiceAbstraction {
/**
@@ -16,6 +15,12 @@ export abstract class DeviceTrustServiceAbstraction {
*/
supportsDeviceTrust$: Observable<boolean>;
/**
* Emits when a device has been trusted. This emission is specifically for the purpose of notifying
* the consuming component to display a toast informing the user the device has been trusted.
*/
deviceTrusted$: Observable<void>;
/**
* @description Checks if the device trust feature is supported for the given user.
*/

View File

@@ -1,34 +1,34 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { firstValueFrom, map, Observable } from "rxjs";
import { firstValueFrom, map, Observable, Subject } from "rxjs";
import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
import { KeyService } from "@bitwarden/key-management";
import { EncryptService } from "../../key-management/crypto/abstractions/encrypt.service";
import { AppIdService } from "../../platform/abstractions/app-id.service";
import { ConfigService } from "../../platform/abstractions/config/config.service";
import { CryptoFunctionService } from "../../platform/abstractions/crypto-function.service";
import { I18nService } from "../../platform/abstractions/i18n.service";
import { KeyGenerationService } from "../../platform/abstractions/key-generation.service";
import { LogService } from "../../platform/abstractions/log.service";
import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service";
import { AbstractStorageService } from "../../platform/abstractions/storage.service";
import { StorageLocation } from "../../platform/enums";
import { EncString } from "../../platform/models/domain/enc-string";
import { StorageOptions } from "../../platform/models/domain/storage-options";
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
import { DEVICE_TRUST_DISK_LOCAL, StateProvider, UserKeyDefinition } from "../../platform/state";
import { UserId } from "../../types/guid";
import { UserKey, DeviceKey } from "../../types/key";
import { DeviceTrustServiceAbstraction } from "../abstractions/device-trust.service.abstraction";
import { DeviceResponse } from "../abstractions/devices/responses/device.response";
import { DevicesApiServiceAbstraction } from "../abstractions/devices-api.service.abstraction";
import { SecretVerificationRequest } from "../models/request/secret-verification.request";
import { DeviceResponse } from "../../../auth/abstractions/devices/responses/device.response";
import { DevicesApiServiceAbstraction } from "../../../auth/abstractions/devices-api.service.abstraction";
import { SecretVerificationRequest } from "../../../auth/models/request/secret-verification.request";
import {
DeviceKeysUpdateRequest,
UpdateDevicesTrustRequest,
} from "../models/request/update-devices-trust.request";
} from "../../../auth/models/request/update-devices-trust.request";
import { AppIdService } from "../../../platform/abstractions/app-id.service";
import { ConfigService } from "../../../platform/abstractions/config/config.service";
import { CryptoFunctionService } from "../../../platform/abstractions/crypto-function.service";
import { I18nService } from "../../../platform/abstractions/i18n.service";
import { KeyGenerationService } from "../../../platform/abstractions/key-generation.service";
import { LogService } from "../../../platform/abstractions/log.service";
import { PlatformUtilsService } from "../../../platform/abstractions/platform-utils.service";
import { AbstractStorageService } from "../../../platform/abstractions/storage.service";
import { StorageLocation } from "../../../platform/enums";
import { EncString } from "../../../platform/models/domain/enc-string";
import { StorageOptions } from "../../../platform/models/domain/storage-options";
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
import { DEVICE_TRUST_DISK_LOCAL, StateProvider, UserKeyDefinition } from "../../../platform/state";
import { UserId } from "../../../types/guid";
import { UserKey, DeviceKey } from "../../../types/key";
import { EncryptService } from "../../crypto/abstractions/encrypt.service";
import { DeviceTrustServiceAbstraction } from "../abstractions/device-trust.service.abstraction";
/** Uses disk storage so that the device key can persist after log out and tab removal. */
export const DEVICE_KEY = new UserKeyDefinition<DeviceKey | null>(
@@ -63,6 +63,10 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
supportsDeviceTrust$: Observable<boolean>;
// Observable emission is used to trigger a toast in consuming components
private deviceTrustedSubject = new Subject<void>();
deviceTrusted$ = this.deviceTrustedSubject.asObservable();
constructor(
private keyGenerationService: KeyGenerationService,
private cryptoFunctionService: CryptoFunctionService,
@@ -177,7 +181,8 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
// store device key in local/secure storage if enc keys posted to server successfully
await this.setDeviceKey(userId, deviceKey);
this.platformUtilsService.showToast("success", null, this.i18nService.t("deviceTrusted"));
// This emission will be picked up by consuming components to handle displaying a toast to the user
this.deviceTrustedSubject.next();
return deviceResponse;
}

View File

@@ -3,38 +3,38 @@
import { matches, mock } from "jest-mock-extended";
import { BehaviorSubject, firstValueFrom, of } from "rxjs";
import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
import {
UserDecryptionOptionsServiceAbstraction,
UserDecryptionOptions,
} from "@bitwarden/auth/common";
import { KeyService } from "@bitwarden/key-management";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { UserDecryptionOptions } from "../../../../auth/src/common/models/domain/user-decryption-options";
import { FakeAccountService, mockAccountServiceWith } from "../../../spec/fake-account-service";
import { FakeActiveUserState } from "../../../spec/fake-state";
import { FakeStateProvider } from "../../../spec/fake-state-provider";
import { DeviceType } from "../../enums";
import { EncryptService } from "../../key-management/crypto/abstractions/encrypt.service";
import { AppIdService } from "../../platform/abstractions/app-id.service";
import { ConfigService } from "../../platform/abstractions/config/config.service";
import { CryptoFunctionService } from "../../platform/abstractions/crypto-function.service";
import { I18nService } from "../../platform/abstractions/i18n.service";
import { KeyGenerationService } from "../../platform/abstractions/key-generation.service";
import { LogService } from "../../platform/abstractions/log.service";
import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service";
import { AbstractStorageService } from "../../platform/abstractions/storage.service";
import { StorageLocation } from "../../platform/enums";
import { EncryptionType } from "../../platform/enums/encryption-type.enum";
import { Utils } from "../../platform/misc/utils";
import { EncString } from "../../platform/models/domain/enc-string";
import { StorageOptions } from "../../platform/models/domain/storage-options";
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
import { CsprngArray } from "../../types/csprng";
import { UserId } from "../../types/guid";
import { DeviceKey, UserKey } from "../../types/key";
import { DeviceResponse } from "../abstractions/devices/responses/device.response";
import { DevicesApiServiceAbstraction } from "../abstractions/devices-api.service.abstraction";
import { UpdateDevicesTrustRequest } from "../models/request/update-devices-trust.request";
import { ProtectedDeviceResponse } from "../models/response/protected-device.response";
import { FakeAccountService, mockAccountServiceWith } from "../../../../spec/fake-account-service";
import { FakeActiveUserState } from "../../../../spec/fake-state";
import { FakeStateProvider } from "../../../../spec/fake-state-provider";
import { DeviceResponse } from "../../../auth/abstractions/devices/responses/device.response";
import { DevicesApiServiceAbstraction } from "../../../auth/abstractions/devices-api.service.abstraction";
import { UpdateDevicesTrustRequest } from "../../../auth/models/request/update-devices-trust.request";
import { ProtectedDeviceResponse } from "../../../auth/models/response/protected-device.response";
import { DeviceType } from "../../../enums";
import { AppIdService } from "../../../platform/abstractions/app-id.service";
import { ConfigService } from "../../../platform/abstractions/config/config.service";
import { CryptoFunctionService } from "../../../platform/abstractions/crypto-function.service";
import { I18nService } from "../../../platform/abstractions/i18n.service";
import { KeyGenerationService } from "../../../platform/abstractions/key-generation.service";
import { LogService } from "../../../platform/abstractions/log.service";
import { PlatformUtilsService } from "../../../platform/abstractions/platform-utils.service";
import { AbstractStorageService } from "../../../platform/abstractions/storage.service";
import { StorageLocation } from "../../../platform/enums";
import { EncryptionType } from "../../../platform/enums/encryption-type.enum";
import { Utils } from "../../../platform/misc/utils";
import { EncString } from "../../../platform/models/domain/enc-string";
import { StorageOptions } from "../../../platform/models/domain/storage-options";
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
import { CsprngArray } from "../../../types/csprng";
import { UserId } from "../../../types/guid";
import { DeviceKey, UserKey } from "../../../types/key";
import { EncryptService } from "../../crypto/abstractions/encrypt.service";
import {
SHOULD_TRUST_DEVICE,

View File

@@ -1,8 +1,8 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Organization } from "../../admin-console/models/domain/organization";
import { UserId } from "../../types/guid";
import { IdentityTokenResponse } from "../models/response/identity-token.response";
import { Organization } from "../../../admin-console/models/domain/organization";
import { IdentityTokenResponse } from "../../../auth/models/response/identity-token.response";
import { UserId } from "../../../types/guid";
export abstract class KeyConnectorService {
setMasterKeyFromUrl: (url: string, userId: UserId) => Promise<void>;

View File

@@ -4,27 +4,27 @@ import { of } from "rxjs";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { KeyService } from "@bitwarden/key-management";
import { FakeAccountService, FakeStateProvider, mockAccountServiceWith } from "../../../spec";
import { ApiService } from "../../abstractions/api.service";
import { OrganizationData } from "../../admin-console/models/data/organization.data";
import { Organization } from "../../admin-console/models/domain/organization";
import { ProfileOrganizationResponse } from "../../admin-console/models/response/profile-organization.response";
import { LogService } from "../../platform/abstractions/log.service";
import { Utils } from "../../platform/misc/utils";
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
import { KeyGenerationService } from "../../platform/services/key-generation.service";
import { OrganizationId, UserId } from "../../types/guid";
import { MasterKey } from "../../types/key";
import { KeyConnectorUserKeyRequest } from "../models/request/key-connector-user-key.request";
import { KeyConnectorUserKeyResponse } from "../models/response/key-connector-user-key.response";
import { FakeAccountService, FakeStateProvider, mockAccountServiceWith } from "../../../../spec";
import { ApiService } from "../../../abstractions/api.service";
import { OrganizationData } from "../../../admin-console/models/data/organization.data";
import { Organization } from "../../../admin-console/models/domain/organization";
import { ProfileOrganizationResponse } from "../../../admin-console/models/response/profile-organization.response";
import { KeyConnectorUserKeyResponse } from "../../../auth/models/response/key-connector-user-key.response";
import { TokenService } from "../../../auth/services/token.service";
import { LogService } from "../../../platform/abstractions/log.service";
import { Utils } from "../../../platform/misc/utils";
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
import { KeyGenerationService } from "../../../platform/services/key-generation.service";
import { OrganizationId, UserId } from "../../../types/guid";
import { MasterKey } from "../../../types/key";
import { FakeMasterPasswordService } from "../../master-password/services/fake-master-password.service";
import { KeyConnectorUserKeyRequest } from "../models/key-connector-user-key.request";
import {
USES_KEY_CONNECTOR,
CONVERT_ACCOUNT_TO_KEY_CONNECTOR,
KeyConnectorService,
} from "./key-connector.service";
import { FakeMasterPasswordService } from "./master-password/fake-master-password.service";
import { TokenService } from "./token.service";
describe("KeyConnectorService", () => {
let keyConnectorService: KeyConnectorService;

View File

@@ -3,8 +3,6 @@
import { firstValueFrom } from "rxjs";
import { LogoutReason } from "@bitwarden/auth/common";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import {
Argon2KdfConfig,
KdfConfig,
@@ -13,28 +11,30 @@ import {
KdfType,
} from "@bitwarden/key-management";
import { ApiService } from "../../abstractions/api.service";
import { OrganizationUserType } from "../../admin-console/enums";
import { Organization } from "../../admin-console/models/domain/organization";
import { KeysRequest } from "../../models/request/keys.request";
import { KeyGenerationService } from "../../platform/abstractions/key-generation.service";
import { LogService } from "../../platform/abstractions/log.service";
import { Utils } from "../../platform/misc/utils";
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
import { ApiService } from "../../../abstractions/api.service";
import { OrganizationService } from "../../../admin-console/abstractions/organization/organization.service.abstraction";
import { OrganizationUserType } from "../../../admin-console/enums";
import { Organization } from "../../../admin-console/models/domain/organization";
import { AccountService } from "../../../auth/abstractions/account.service";
import { TokenService } from "../../../auth/abstractions/token.service";
import { IdentityTokenResponse } from "../../../auth/models/response/identity-token.response";
import { KeysRequest } from "../../../models/request/keys.request";
import { KeyGenerationService } from "../../../platform/abstractions/key-generation.service";
import { LogService } from "../../../platform/abstractions/log.service";
import { Utils } from "../../../platform/misc/utils";
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
import {
ActiveUserState,
KEY_CONNECTOR_DISK,
StateProvider,
UserKeyDefinition,
} from "../../platform/state";
import { UserId } from "../../types/guid";
import { MasterKey } from "../../types/key";
} from "../../../platform/state";
import { UserId } from "../../../types/guid";
import { MasterKey } from "../../../types/key";
import { InternalMasterPasswordServiceAbstraction } from "../../master-password/abstractions/master-password.service.abstraction";
import { KeyConnectorService as KeyConnectorServiceAbstraction } from "../abstractions/key-connector.service";
import { InternalMasterPasswordServiceAbstraction } from "../abstractions/master-password.service.abstraction";
import { TokenService } from "../abstractions/token.service";
import { KeyConnectorUserKeyRequest } from "../models/request/key-connector-user-key.request";
import { SetKeyConnectorKeyRequest } from "../models/request/set-key-connector-key.request";
import { IdentityTokenResponse } from "../models/response/identity-token.response";
import { KeyConnectorUserKeyRequest } from "../models/key-connector-user-key.request";
import { SetKeyConnectorKeyRequest } from "../models/set-key-connector-key.request";
export const USES_KEY_CONNECTOR = new UserKeyDefinition<boolean | null>(
KEY_CONNECTOR_DISK,

View File

@@ -1,9 +1,9 @@
import { Observable } from "rxjs";
import { EncString } from "../../platform/models/domain/enc-string";
import { UserId } from "../../types/guid";
import { MasterKey, UserKey } from "../../types/key";
import { ForceSetPasswordReason } from "../models/domain/force-set-password-reason";
import { ForceSetPasswordReason } from "../../../auth/models/domain/force-set-password-reason";
import { EncString } from "../../../platform/models/domain/enc-string";
import { UserId } from "../../../types/guid";
import { MasterKey, UserKey } from "../../../types/key";
export abstract class MasterPasswordServiceAbstraction {
/**

View File

@@ -3,11 +3,11 @@
import { mock } from "jest-mock-extended";
import { ReplaySubject, Observable } from "rxjs";
import { ForceSetPasswordReason } from "../../../auth/models/domain/force-set-password-reason";
import { EncString } from "../../../platform/models/domain/enc-string";
import { UserId } from "../../../types/guid";
import { MasterKey, UserKey } from "../../../types/key";
import { InternalMasterPasswordServiceAbstraction } from "../../abstractions/master-password.service.abstraction";
import { ForceSetPasswordReason } from "../../models/domain/force-set-password-reason";
import { InternalMasterPasswordServiceAbstraction } from "../abstractions/master-password.service.abstraction";
export class FakeMasterPasswordService implements InternalMasterPasswordServiceAbstraction {
mock = mock<InternalMasterPasswordServiceAbstraction>();

View File

@@ -2,7 +2,7 @@
// @ts-strict-ignore
import { firstValueFrom, map, Observable } from "rxjs";
import { EncryptService } from "../../../key-management/crypto/abstractions/encrypt.service";
import { ForceSetPasswordReason } from "../../../auth/models/domain/force-set-password-reason";
import { KeyGenerationService } from "../../../platform/abstractions/key-generation.service";
import { LogService } from "../../../platform/abstractions/log.service";
import { StateService } from "../../../platform/abstractions/state.service";
@@ -17,8 +17,8 @@ import {
} from "../../../platform/state";
import { UserId } from "../../../types/guid";
import { MasterKey, UserKey } from "../../../types/key";
import { InternalMasterPasswordServiceAbstraction } from "../../abstractions/master-password.service.abstraction";
import { ForceSetPasswordReason } from "../../models/domain/force-set-password-reason";
import { EncryptService } from "../../crypto/abstractions/encrypt.service";
import { InternalMasterPasswordServiceAbstraction } from "../abstractions/master-password.service.abstraction";
/** Memory since master key shouldn't be available on lock */
const MASTER_KEY = new UserKeyDefinition<MasterKey>(MASTER_PASSWORD_MEMORY, "masterKey", {

View File

@@ -12,7 +12,6 @@ import { SearchService } from "../../../abstractions/search.service";
import { AccountInfo } from "../../../auth/abstractions/account.service";
import { AuthService } from "../../../auth/abstractions/auth.service";
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
import { FakeMasterPasswordService } from "../../../auth/services/master-password/fake-master-password.service";
import { LogService } from "../../../platform/abstractions/log.service";
import { MessagingService } from "../../../platform/abstractions/messaging.service";
import { PlatformUtilsService } from "../../../platform/abstractions/platform-utils.service";
@@ -23,6 +22,7 @@ import { StateEventRunnerService } from "../../../platform/state";
import { UserId } from "../../../types/guid";
import { CipherService } from "../../../vault/abstractions/cipher.service";
import { FolderService } from "../../../vault/abstractions/folder/folder.service.abstraction";
import { FakeMasterPasswordService } from "../../master-password/services/fake-master-password.service";
import { VaultTimeoutAction } from "../enums/vault-timeout-action.enum";
import { VaultTimeout, VaultTimeoutStringType } from "../types/vault-timeout.type";

View File

@@ -9,7 +9,6 @@ import { BiometricsService } from "@bitwarden/key-management";
import { SearchService } from "../../../abstractions/search.service";
import { AccountService } from "../../../auth/abstractions/account.service";
import { AuthService } from "../../../auth/abstractions/auth.service";
import { InternalMasterPasswordServiceAbstraction } from "../../../auth/abstractions/master-password.service.abstraction";
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
import { LogService } from "../../../platform/abstractions/log.service";
import { MessagingService } from "../../../platform/abstractions/messaging.service";
@@ -20,6 +19,7 @@ import { StateEventRunnerService } from "../../../platform/state";
import { UserId } from "../../../types/guid";
import { CipherService } from "../../../vault/abstractions/cipher.service";
import { FolderService } from "../../../vault/abstractions/folder/folder.service.abstraction";
import { InternalMasterPasswordServiceAbstraction } from "../../master-password/abstractions/master-password.service.abstraction";
import { VaultTimeoutSettingsService } from "../abstractions/vault-timeout-settings.service";
import { VaultTimeoutService as VaultTimeoutServiceAbstraction } from "../abstractions/vault-timeout.service";
import { VaultTimeoutAction } from "../enums/vault-timeout-action.enum";
@@ -138,10 +138,11 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
);
if (userId == null || userId === currentUserId) {
await this.searchService.clearIndex();
await this.collectionService.clearActiveUserCache();
}
await this.searchService.clearIndex(lockingUserId);
await this.folderService.clearDecryptedFolderState(lockingUserId);
await this.masterPasswordService.clearMasterKey(lockingUserId);

View File

@@ -73,6 +73,7 @@ export class CipherExport {
break;
case CipherType.SshKey:
view.sshKey = SshKeyExport.toView(req.sshKey);
break;
}
if (req.passwordHistory != null) {

View File

@@ -1,5 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { import_ssh_key } from "@bitwarden/sdk-internal";
import { EncString } from "../../platform/models/domain/enc-string";
import { SshKey as SshKeyDomain } from "../../vault/models/domain/ssh-key";
@@ -17,16 +18,18 @@ export class SshKeyExport {
}
static toView(req: SshKeyExport, view = new SshKeyView()) {
view.privateKey = req.privateKey;
view.publicKey = req.publicKey;
view.keyFingerprint = req.keyFingerprint;
const parsedKey = import_ssh_key(req.privateKey);
view.privateKey = parsedKey.privateKey;
view.publicKey = parsedKey.publicKey;
view.keyFingerprint = parsedKey.fingerprint;
return view;
}
static toDomain(req: SshKeyExport, domain = new SshKeyDomain()) {
domain.privateKey = req.privateKey != null ? new EncString(req.privateKey) : null;
domain.publicKey = req.publicKey != null ? new EncString(req.publicKey) : null;
domain.keyFingerprint = req.keyFingerprint != null ? new EncString(req.keyFingerprint) : null;
const parsedKey = import_ssh_key(req.privateKey);
domain.privateKey = new EncString(parsedKey.privateKey);
domain.publicKey = new EncString(parsedKey.publicKey);
domain.keyFingerprint = new EncString(parsedKey.fingerprint);
return domain;
}

View File

@@ -1,6 +1,6 @@
export enum EncryptionType {
AesCbc256_B64 = 0,
AesCbc128_HmacSha256_B64 = 1,
// Type 1 was the unused and removed AesCbc128_HmacSha256_B64
AesCbc256_HmacSha256_B64 = 2,
Rsa2048_OaepSha256_B64 = 3,
Rsa2048_OaepSha1_B64 = 4,
@@ -17,12 +17,10 @@ export function encryptionTypeToString(encryptionType: EncryptionType): string {
}
/** The expected number of parts to a serialized EncString of the given encryption type.
* For example, an EncString of type AesCbc256_B64 will have 2 parts, and an EncString of type
* AesCbc128_HmacSha256_B64 will have 3 parts.
* For example, an EncString of type AesCbc256_B64 will have 2 parts
*
* Example of annotated serialized EncStrings:
* 0.iv|data
* 1.iv|data|mac
* 2.iv|data|mac
* 3.data
* 4.data
@@ -33,7 +31,6 @@ export function encryptionTypeToString(encryptionType: EncryptionType): string {
*/
export const EXPECTED_NUM_PARTS_BY_ENCRYPTION_TYPE = {
[EncryptionType.AesCbc256_B64]: 2,
[EncryptionType.AesCbc128_HmacSha256_B64]: 3,
[EncryptionType.AesCbc256_HmacSha256_B64]: 3,
[EncryptionType.Rsa2048_OaepSha256_B64]: 1,
[EncryptionType.Rsa2048_OaepSha1_B64]: 1,

View File

@@ -5,16 +5,12 @@ export enum ThemeType {
System = "system",
Light = "light",
Dark = "dark",
Nord = "nord",
SolarizedDark = "solarizedDark",
}
export const ThemeTypes = {
System: "system",
Light: "light",
Dark: "dark",
Nord: "nord",
SolarizedDark: "solarizedDark",
} as const;
export type Theme = (typeof ThemeTypes)[keyof typeof ThemeTypes];

View File

@@ -1,5 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { ConditionalExcept, ConditionalKeys, Constructor } from "type-fest";
import { EncryptService } from "../../../key-management/crypto/abstractions/encrypt.service";
@@ -15,6 +13,19 @@ export type DecryptedObject<
TDecryptedKeys extends EncStringKeys<TEncryptedObject>,
> = Record<TDecryptedKeys, string> & Omit<TEncryptedObject, TDecryptedKeys>;
// extracts shared keys from the domain and view types
type EncryptableKeys<D extends Domain, V extends View> = (keyof D &
ConditionalKeys<D, EncString | null>) &
(keyof V & ConditionalKeys<V, string | null>);
type DomainEncryptableKeys<D extends Domain> = {
[key in ConditionalKeys<D, EncString | null>]: EncString | null;
};
type ViewEncryptableKeys<V extends View> = {
[key in ConditionalKeys<V, string | null>]: string | null;
};
// https://contributing.bitwarden.com/architecture/clients/data-model#domain
export default class Domain {
protected buildDomainModel<D extends Domain>(
@@ -37,6 +48,7 @@ export default class Domain {
}
}
}
protected buildDataModel<D extends Domain>(
domain: D,
dataObj: any,
@@ -58,31 +70,24 @@ export default class Domain {
}
}
protected async decryptObj<T extends View>(
viewModel: T,
map: any,
orgId: string,
key: SymmetricCryptoKey = null,
protected async decryptObj<D extends Domain, V extends View>(
domain: DomainEncryptableKeys<D>,
viewModel: ViewEncryptableKeys<V>,
props: EncryptableKeys<D, V>[],
orgId: string | null,
key: SymmetricCryptoKey | null = null,
objectContext: string = "No Domain Context",
): Promise<T> {
const self: any = this;
for (const prop in map) {
// eslint-disable-next-line
if (!map.hasOwnProperty(prop)) {
continue;
}
const mapProp = map[prop] || prop;
if (self[mapProp]) {
(viewModel as any)[prop] = await self[mapProp].decrypt(
): Promise<V> {
for (const prop of props) {
viewModel[prop] =
(await domain[prop]?.decrypt(
orgId,
key,
`Property: ${prop}; ObjectContext: ${objectContext}`,
);
}
`Property: ${prop as string}; ObjectContext: ${objectContext}`,
)) ?? null;
}
return viewModel;
return viewModel as V;
}
/**
@@ -111,7 +116,7 @@ export default class Domain {
const decryptedObjects = [];
for (const prop of encryptedProperties) {
const value = (this as any)[prop] as EncString;
const value = this[prop] as EncString;
const decrypted = await this.decryptProperty(
prop,
value,
@@ -138,11 +143,9 @@ export default class Domain {
encryptService: EncryptService,
decryptTrace: string,
) {
let decrypted: string = null;
let decrypted: string | null = null;
if (value) {
decrypted = await value.decryptWithKey(key, encryptService, decryptTrace);
} else {
decrypted = null;
}
return {
[propertyKey]: decrypted,

View File

@@ -5,28 +5,28 @@ import { EncArrayBuffer } from "./enc-array-buffer";
describe("encArrayBuffer", () => {
describe("parses the buffer", () => {
test.each([
[EncryptionType.AesCbc128_HmacSha256_B64, "AesCbc128_HmacSha256_B64"],
[EncryptionType.AesCbc256_HmacSha256_B64, "AesCbc256_HmacSha256_B64"],
])("with %c%s", (encType: EncryptionType) => {
const iv = makeStaticByteArray(16, 10);
const mac = makeStaticByteArray(32, 20);
// We use the minimum data length of 1 to test the boundary of valid lengths
const data = makeStaticByteArray(1, 100);
test.each([[EncryptionType.AesCbc256_HmacSha256_B64, "AesCbc256_HmacSha256_B64"]])(
"with %c%s",
(encType: EncryptionType) => {
const iv = makeStaticByteArray(16, 10);
const mac = makeStaticByteArray(32, 20);
// We use the minimum data length of 1 to test the boundary of valid lengths
const data = makeStaticByteArray(1, 100);
const array = new Uint8Array(1 + iv.byteLength + mac.byteLength + data.byteLength);
array.set([encType]);
array.set(iv, 1);
array.set(mac, 1 + iv.byteLength);
array.set(data, 1 + iv.byteLength + mac.byteLength);
const array = new Uint8Array(1 + iv.byteLength + mac.byteLength + data.byteLength);
array.set([encType]);
array.set(iv, 1);
array.set(mac, 1 + iv.byteLength);
array.set(data, 1 + iv.byteLength + mac.byteLength);
const actual = new EncArrayBuffer(array);
const actual = new EncArrayBuffer(array);
expect(actual.encryptionType).toEqual(encType);
expect(actual.ivBytes).toEqualBuffer(iv);
expect(actual.macBytes).toEqualBuffer(mac);
expect(actual.dataBytes).toEqualBuffer(data);
});
expect(actual.encryptionType).toEqual(encType);
expect(actual.ivBytes).toEqualBuffer(iv);
expect(actual.macBytes).toEqualBuffer(mac);
expect(actual.dataBytes).toEqualBuffer(data);
},
);
it("with AesCbc256_B64", () => {
const encType = EncryptionType.AesCbc256_B64;
@@ -50,7 +50,6 @@ describe("encArrayBuffer", () => {
describe("throws if the buffer has an invalid length", () => {
test.each([
[EncryptionType.AesCbc128_HmacSha256_B64, 50, "AesCbc128_HmacSha256_B64"],
[EncryptionType.AesCbc256_HmacSha256_B64, 50, "AesCbc256_HmacSha256_B64"],
[EncryptionType.AesCbc256_B64, 18, "AesCbc256_B64"],
])("with %c%c%s", (encType: EncryptionType, minLength: number) => {

View File

@@ -20,7 +20,6 @@ export class EncArrayBuffer implements Encrypted {
const encType = encBytes[0];
switch (encType) {
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) {

View File

@@ -60,9 +60,7 @@ describe("EncString", () => {
const cases = [
"aXY=|Y3Q=", // AesCbc256_B64 w/out header
"aXY=|Y3Q=|cnNhQ3Q=", // AesCbc128_HmacSha256_B64 w/out header
"0.QmFzZTY0UGFydA==|QmFzZTY0UGFydA==", // AesCbc256_B64 with header
"1.QmFzZTY0UGFydA==|QmFzZTY0UGFydA==|QmFzZTY0UGFydA==", // AesCbc128_HmacSha256_B64
"2.QmFzZTY0UGFydA==|QmFzZTY0UGFydA==|QmFzZTY0UGFydA==", // AesCbc256_HmacSha256_B64
"3.QmFzZTY0UGFydA==", // Rsa2048_OaepSha256_B64
"4.QmFzZTY0UGFydA==", // Rsa2048_OaepSha1_B64

View File

@@ -89,7 +89,6 @@ export class EncString implements Encrypted {
}
switch (encType) {
case EncryptionType.AesCbc128_HmacSha256_B64:
case EncryptionType.AesCbc256_HmacSha256_B64:
this.iv = encPieces[0];
this.data = encPieces[1];
@@ -132,10 +131,7 @@ export class EncString implements Encrypted {
}
} else {
encPieces = encryptedString.split("|");
encType =
encPieces.length === 3
? EncryptionType.AesCbc128_HmacSha256_B64
: EncryptionType.AesCbc256_B64;
encType = EncryptionType.AesCbc256_B64;
}
return {
@@ -160,7 +156,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) {

View File

@@ -27,21 +27,6 @@ describe("SymmetricCryptoKey", () => {
});
});
it("AesCbc128_HmacSha256_B64", () => {
const key = makeStaticByteArray(32);
const cryptoKey = new SymmetricCryptoKey(key, EncryptionType.AesCbc128_HmacSha256_B64);
expect(cryptoKey).toEqual({
encKey: key.slice(0, 16),
encKeyB64: "AAECAwQFBgcICQoLDA0ODw==",
encType: 1,
key: key,
keyB64: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=",
macKey: key.slice(16, 32),
macKeyB64: "EBESExQVFhcYGRobHB0eHw==",
});
});
it("AesCbc256_HmacSha256_B64", () => {
const key = makeStaticByteArray(64);
const cryptoKey = new SymmetricCryptoKey(key);

View File

@@ -38,9 +38,6 @@ export class SymmetricCryptoKey {
if (encType === EncryptionType.AesCbc256_B64 && key.byteLength === 32) {
this.encKey = key;
this.macKey = null;
} else if (encType === EncryptionType.AesCbc128_HmacSha256_B64 && key.byteLength === 32) {
this.encKey = key.slice(0, 16);
this.macKey = key.slice(16, 32);
} else if (encType === EncryptionType.AesCbc256_HmacSha256_B64 && key.byteLength === 64) {
this.encKey = key.slice(0, 32);
this.macKey = key.slice(32, 64);

View File

@@ -131,6 +131,10 @@ export const THEMING_DISK = new StateDefinition("theming", "disk", { web: "disk-
export const TRANSLATION_DISK = new StateDefinition("translation", "disk", { web: "disk-local" });
export const ANIMATION_DISK = new StateDefinition("animation", "disk");
export const TASK_SCHEDULER_DISK = new StateDefinition("taskScheduler", "disk");
export const EXTENSION_INITIAL_INSTALL_DISK = new StateDefinition(
"extensionInitialInstall",
"disk",
);
// Design System
@@ -144,6 +148,7 @@ export const SM_ONBOARDING_DISK = new StateDefinition("smOnboarding", "disk", {
// Tools
export const EXTENSION_DISK = new StateDefinition("extension", "disk");
export const GENERATOR_DISK = new StateDefinition("generator", "disk");
export const GENERATOR_MEMORY = new StateDefinition("generator", "memory");
export const BROWSER_SEND_MEMORY = new StateDefinition("sendBrowser", "memory");
@@ -200,3 +205,4 @@ export const NEW_DEVICE_VERIFICATION_NOTICE = new StateDefinition(
export const VAULT_APPEARANCE = new StateDefinition("vaultAppearance", "disk");
export const SECURITY_TASKS_DISK = new StateDefinition("securityTasks", "disk");
export const AT_RISK_PASSWORDS_PAGE_DISK = new StateDefinition("atRiskPasswordsPage", "disk");
export const NOTIFICATION_DISK = new StateDefinition("notifications", "disk");

View File

@@ -27,13 +27,13 @@ import { PolicyResponse } from "../../admin-console/models/response/policy.respo
import { AccountService } from "../../auth/abstractions/account.service";
import { AuthService } from "../../auth/abstractions/auth.service";
import { AvatarService } from "../../auth/abstractions/avatar.service";
import { KeyConnectorService } from "../../auth/abstractions/key-connector.service";
import { InternalMasterPasswordServiceAbstraction } from "../../auth/abstractions/master-password.service.abstraction";
import { TokenService } from "../../auth/abstractions/token.service";
import { AuthenticationStatus } from "../../auth/enums/authentication-status";
import { ForceSetPasswordReason } from "../../auth/models/domain/force-set-password-reason";
import { DomainSettingsService } from "../../autofill/services/domain-settings.service";
import { BillingAccountProfileStateService } from "../../billing/abstractions";
import { KeyConnectorService } from "../../key-management/key-connector/abstractions/key-connector.service";
import { InternalMasterPasswordServiceAbstraction } from "../../key-management/master-password/abstractions/master-password.service.abstraction";
import { DomainsResponse } from "../../models/response/domains.response";
import { ProfileResponse } from "../../models/response/profile.response";
import { SendData } from "../../tools/send/models/data/send.data";

View File

@@ -1,43 +1,33 @@
import { Observable, combineLatest, map } from "rxjs";
import { Observable, map } from "rxjs";
import { FeatureFlag } from "../../enums/feature-flag.enum";
import { ConfigService } from "../abstractions/config/config.service";
import { ThemeType } from "../enums";
import { Theme, ThemeTypes } from "../enums";
import { GlobalStateProvider, KeyDefinition, THEMING_DISK } from "../state";
export abstract class ThemeStateService {
/**
* The users selected theme.
*/
abstract selectedTheme$: Observable<ThemeType>;
abstract selectedTheme$: Observable<Theme>;
/**
* A method for updating the current users configured theme.
* @param theme The chosen user theme.
*/
abstract setSelectedTheme(theme: ThemeType): Promise<void>;
abstract setSelectedTheme(theme: Theme): Promise<void>;
}
export const THEME_SELECTION = new KeyDefinition<ThemeType>(THEMING_DISK, "selection", {
export const THEME_SELECTION = new KeyDefinition<Theme>(THEMING_DISK, "selection", {
deserializer: (s) => s,
});
export class DefaultThemeStateService implements ThemeStateService {
private readonly selectedThemeState = this.globalStateProvider.get(THEME_SELECTION);
selectedTheme$ = combineLatest([
this.selectedThemeState.state$,
this.configService.getFeatureFlag$(FeatureFlag.ExtensionRefresh),
]).pipe(
map(([theme, isExtensionRefresh]) => {
// The extension refresh should not allow for Nord or SolarizedDark
// Default the user to their system theme
if (
isExtensionRefresh &&
theme != null &&
[ThemeType.Nord, ThemeType.SolarizedDark].includes(theme)
) {
return ThemeType.System;
selectedTheme$ = this.selectedThemeState.state$.pipe(
map((theme) => {
// We used to support additional themes. Since these are no longer supported we return null to default to the system theme.
if (theme != null && !Object.values(ThemeTypes).includes(theme)) {
return null;
}
return theme;
@@ -47,11 +37,10 @@ export class DefaultThemeStateService implements ThemeStateService {
constructor(
private globalStateProvider: GlobalStateProvider,
private configService: ConfigService,
private defaultTheme: ThemeType = ThemeType.System,
private defaultTheme: Theme = ThemeTypes.System,
) {}
async setSelectedTheme(theme: ThemeType): Promise<void> {
async setSelectedTheme(theme: Theme): Promise<void> {
await this.selectedThemeState.update(() => theme, {
shouldUpdate: (currentTheme) => currentTheme !== theme,
});

View File

@@ -12,6 +12,7 @@ import { LogoutReason } from "@bitwarden/auth/common";
import { ApiService as ApiServiceAbstraction } from "../abstractions/api.service";
import { OrganizationConnectionType } from "../admin-console/enums";
import { CollectionBulkDeleteRequest } from "../admin-console/models/request/collection-bulk-delete.request";
import { OrganizationSponsorshipCreateRequest } from "../admin-console/models/request/organization/organization-sponsorship-create.request";
import { OrganizationSponsorshipRedeemRequest } from "../admin-console/models/request/organization/organization-sponsorship-redeem.request";
import { OrganizationConnectionRequest } from "../admin-console/models/request/organization-connection.request";
@@ -43,7 +44,6 @@ import {
} from "../admin-console/models/response/provider/provider-user.response";
import { SelectionReadOnlyResponse } from "../admin-console/models/response/selection-read-only.response";
import { TokenService } from "../auth/abstractions/token.service";
import { AuthRequest } from "../auth/models/request/auth.request";
import { DeviceVerificationRequest } from "../auth/models/request/device-verification.request";
import { DisableTwoFactorAuthenticatorRequest } from "../auth/models/request/disable-two-factor-authenticator.request";
import { EmailTokenRequest } from "../auth/models/request/email-token.request";
@@ -54,19 +54,12 @@ import { SsoTokenRequest } from "../auth/models/request/identity-token/sso-token
import { TokenTwoFactorRequest } from "../auth/models/request/identity-token/token-two-factor.request";
import { UserApiTokenRequest } from "../auth/models/request/identity-token/user-api-token.request";
import { WebAuthnLoginTokenRequest } from "../auth/models/request/identity-token/webauthn-login-token.request";
import { KeyConnectorUserKeyRequest } from "../auth/models/request/key-connector-user-key.request";
import { PasswordHintRequest } from "../auth/models/request/password-hint.request";
import { PasswordRequest } from "../auth/models/request/password.request";
import { PasswordlessAuthRequest } from "../auth/models/request/passwordless-auth.request";
import { SecretVerificationRequest } from "../auth/models/request/secret-verification.request";
import { SetKeyConnectorKeyRequest } from "../auth/models/request/set-key-connector-key.request";
import { SetPasswordRequest } from "../auth/models/request/set-password.request";
import { TwoFactorEmailRequest } from "../auth/models/request/two-factor-email.request";
import { TwoFactorProviderRequest } from "../auth/models/request/two-factor-provider.request";
import { TwoFactorRecoveryRequest } from "../auth/models/request/two-factor-recovery.request";
import { UpdateProfileRequest } from "../auth/models/request/update-profile.request";
import { UpdateTdeOffboardingPasswordRequest } from "../auth/models/request/update-tde-offboarding-password.request";
import { UpdateTempPasswordRequest } from "../auth/models/request/update-temp-password.request";
import { UpdateTwoFactorAuthenticatorRequest } from "../auth/models/request/update-two-factor-authenticator.request";
import { UpdateTwoFactorDuoRequest } from "../auth/models/request/update-two-factor-duo.request";
import { UpdateTwoFactorEmailRequest } from "../auth/models/request/update-two-factor-email.request";
@@ -104,9 +97,10 @@ import { PlanResponse } from "../billing/models/response/plan.response";
import { SubscriptionResponse } from "../billing/models/response/subscription.response";
import { TaxInfoResponse } from "../billing/models/response/tax-info.response";
import { DeviceType } from "../enums";
import { KeyConnectorUserKeyRequest } from "../key-management/key-connector/models/key-connector-user-key.request";
import { SetKeyConnectorKeyRequest } from "../key-management/key-connector/models/set-key-connector-key.request";
import { VaultTimeoutSettingsService } from "../key-management/vault-timeout";
import { VaultTimeoutAction } from "../key-management/vault-timeout/enums/vault-timeout-action.enum";
import { CollectionBulkDeleteRequest } from "../models/request/collection-bulk-delete.request";
import { DeleteRecoverRequest } from "../models/request/delete-recover.request";
import { EventRequest } from "../models/request/event.request";
import { KdfRequest } from "../models/request/kdf.request";
@@ -279,22 +273,6 @@ export class ApiService implements ApiServiceAbstraction {
}
// TODO: PM-3519: Create and move to AuthRequest Api service
// TODO: PM-9724: Remove legacy auth request methods when we remove legacy LoginViaAuthRequestV1Components
async postAuthRequest(request: AuthRequest): Promise<AuthRequestResponse> {
const r = await this.send("POST", "/auth-requests/", request, false, true);
return new AuthRequestResponse(r);
}
async postAdminAuthRequest(request: AuthRequest): Promise<AuthRequestResponse> {
const r = await this.send("POST", "/auth-requests/admin-request", request, true, true);
return new AuthRequestResponse(r);
}
async getAuthResponse(id: string, accessCode: string): Promise<AuthRequestResponse> {
const path = `/auth-requests/${id}/response?code=${accessCode}`;
const r = await this.send("GET", path, null, false, true);
return new AuthRequestResponse(r);
}
async getAuthRequest(id: string): Promise<AuthRequestResponse> {
const path = `/auth-requests/${id}`;
const r = await this.send("GET", path, null, true, true);
@@ -374,14 +352,6 @@ export class ApiService implements ApiServiceAbstraction {
return this.send("POST", "/accounts/email", request, true, false);
}
postPassword(request: PasswordRequest): Promise<any> {
return this.send("POST", "/accounts/password", request, true, false);
}
setPassword(request: SetPasswordRequest): Promise<any> {
return this.send("POST", "/accounts/set-password", request, true, false);
}
postSetKeyConnectorKey(request: SetKeyConnectorKeyRequest): Promise<any> {
return this.send("POST", "/accounts/set-key-connector-key", request, true, false);
}
@@ -479,14 +449,6 @@ export class ApiService implements ApiServiceAbstraction {
return new ApiKeyResponse(r);
}
putUpdateTempPassword(request: UpdateTempPasswordRequest): Promise<any> {
return this.send("PUT", "/accounts/update-temp-password", request, true, false);
}
putUpdateTdeOffboardingPassword(request: UpdateTdeOffboardingPasswordRequest): Promise<void> {
return this.send("PUT", "/accounts/update-tde-offboarding-password", request, true, false);
}
postConvertToKeyConnector(): Promise<void> {
return this.send("POST", "/accounts/convert-to-key-connector", null, true, false);
}
@@ -1101,10 +1063,6 @@ export class ApiService implements ApiServiceAbstraction {
return new TwoFactorProviderResponse(r);
}
postTwoFactorRecover(request: TwoFactorRecoveryRequest): Promise<any> {
return this.send("POST", "/two-factor/recover", request, false, false);
}
postTwoFactorEmailSetup(request: TwoFactorEmailRequest): Promise<any> {
return this.send("POST", "/two-factor/send-email", request, true, false);
}

View File

@@ -9,13 +9,13 @@ import { UriMatchStrategy } from "../models/domain/domain-service";
import { I18nService } from "../platform/abstractions/i18n.service";
import { LogService } from "../platform/abstractions/log.service";
import {
ActiveUserState,
SingleUserState,
StateProvider,
UserKeyDefinition,
VAULT_SEARCH_MEMORY,
} from "../platform/state";
import { SendView } from "../tools/send/models/view/send.view";
import { IndexedEntityId } from "../types/guid";
import { IndexedEntityId, UserId } from "../types/guid";
import { FieldType } from "../vault/enums";
import { CipherType } from "../vault/enums/cipher-type";
import { CipherView } from "../vault/models/view/cipher.view";
@@ -70,24 +70,6 @@ export const LUNR_SEARCH_INDEXING = new UserKeyDefinition<boolean>(
export class SearchService implements SearchServiceAbstraction {
private static registeredPipeline = false;
private searchIndexState: ActiveUserState<SerializedLunrIndex> =
this.stateProvider.getActive(LUNR_SEARCH_INDEX);
private readonly index$: Observable<lunr.Index | null> = this.searchIndexState.state$.pipe(
map((searchIndex) => (searchIndex ? lunr.Index.load(searchIndex) : null)),
);
private searchIndexEntityIdState: ActiveUserState<IndexedEntityId> = this.stateProvider.getActive(
LUNR_SEARCH_INDEXED_ENTITY_ID,
);
readonly indexedEntityId$: Observable<IndexedEntityId | null> =
this.searchIndexEntityIdState.state$.pipe(map((id) => id));
private searchIsIndexingState: ActiveUserState<boolean> =
this.stateProvider.getActive(LUNR_SEARCH_INDEXING);
private readonly searchIsIndexing$: Observable<boolean> = this.searchIsIndexingState.state$.pipe(
map((indexing) => indexing ?? false),
);
private readonly immediateSearchLocales: string[] = ["zh-CN", "zh-TW", "ja", "ko", "vi"];
private readonly defaultSearchableMinLength: number = 2;
private searchableMinLength: number = this.defaultSearchableMinLength;
@@ -114,15 +96,41 @@ export class SearchService implements SearchServiceAbstraction {
}
}
async clearIndex(): Promise<void> {
await this.searchIndexEntityIdState.update(() => null);
await this.searchIndexState.update(() => null);
await this.searchIsIndexingState.update(() => null);
private searchIndexState(userId: UserId): SingleUserState<SerializedLunrIndex> {
return this.stateProvider.getUser(userId, LUNR_SEARCH_INDEX);
}
async isSearchable(query: string): Promise<boolean> {
private index$(userId: UserId): Observable<lunr.Index | null> {
return this.searchIndexState(userId).state$.pipe(
map((searchIndex) => (searchIndex ? lunr.Index.load(searchIndex) : null)),
);
}
private searchIndexEntityIdState(userId: UserId): SingleUserState<IndexedEntityId | null> {
return this.stateProvider.getUser(userId, LUNR_SEARCH_INDEXED_ENTITY_ID);
}
indexedEntityId$(userId: UserId): Observable<IndexedEntityId | null> {
return this.searchIndexEntityIdState(userId).state$.pipe(map((id) => id));
}
private searchIsIndexingState(userId: UserId): SingleUserState<boolean> {
return this.stateProvider.getUser(userId, LUNR_SEARCH_INDEXING);
}
private searchIsIndexing$(userId: UserId): Observable<boolean> {
return this.searchIsIndexingState(userId).state$.pipe(map((indexing) => indexing ?? false));
}
async clearIndex(userId: UserId): Promise<void> {
await this.searchIndexEntityIdState(userId).update(() => null);
await this.searchIndexState(userId).update(() => null);
await this.searchIsIndexingState(userId).update(() => null);
}
async isSearchable(userId: UserId, query: string): Promise<boolean> {
query = SearchService.normalizeSearchQuery(query);
const index = await this.getIndexForSearch();
const index = await this.getIndexForSearch(userId);
const notSearchable =
query == null ||
(index == null && query.length < this.searchableMinLength) ||
@@ -130,13 +138,17 @@ export class SearchService implements SearchServiceAbstraction {
return !notSearchable;
}
async indexCiphers(ciphers: CipherView[], indexedEntityId?: string): Promise<void> {
if (await this.getIsIndexing()) {
async indexCiphers(
userId: UserId,
ciphers: CipherView[],
indexedEntityId?: string,
): Promise<void> {
if (await this.getIsIndexing(userId)) {
return;
}
await this.setIsIndexing(true);
await this.setIndexedEntityIdForSearch(indexedEntityId as IndexedEntityId);
await this.setIsIndexing(userId, true);
await this.setIndexedEntityIdForSearch(userId, indexedEntityId as IndexedEntityId);
const builder = new lunr.Builder();
builder.pipeline.add(this.normalizeAccentsPipelineFunction);
builder.ref("id");
@@ -172,14 +184,15 @@ export class SearchService implements SearchServiceAbstraction {
ciphers.forEach((c) => builder.add(c));
const index = builder.build();
await this.setIndexForSearch(index.toJSON() as SerializedLunrIndex);
await this.setIndexForSearch(userId, index.toJSON() as SerializedLunrIndex);
await this.setIsIndexing(false);
await this.setIsIndexing(userId, false);
this.logService.info("Finished search indexing");
}
async searchCiphers(
userId: UserId,
query: string,
filter: ((cipher: CipherView) => boolean) | ((cipher: CipherView) => boolean)[] = null,
ciphers: CipherView[],
@@ -202,18 +215,18 @@ export class SearchService implements SearchServiceAbstraction {
ciphers = ciphers.filter(filter as (cipher: CipherView) => boolean);
}
if (!(await this.isSearchable(query))) {
if (!(await this.isSearchable(userId, query))) {
return ciphers;
}
if (await this.getIsIndexing()) {
if (await this.getIsIndexing(userId)) {
await new Promise((r) => setTimeout(r, 250));
if (await this.getIsIndexing()) {
if (await this.getIsIndexing(userId)) {
await new Promise((r) => setTimeout(r, 500));
}
}
const index = await this.getIndexForSearch();
const index = await this.getIndexForSearch(userId);
if (index == null) {
// Fall back to basic search if index is not available
return this.searchCiphersBasic(ciphers, query);
@@ -307,24 +320,27 @@ export class SearchService implements SearchServiceAbstraction {
return sendsMatched.concat(lowPriorityMatched);
}
async getIndexForSearch(): Promise<lunr.Index | null> {
return await firstValueFrom(this.index$);
async getIndexForSearch(userId: UserId): Promise<lunr.Index | null> {
return await firstValueFrom(this.index$(userId));
}
private async setIndexForSearch(index: SerializedLunrIndex): Promise<void> {
await this.searchIndexState.update(() => index);
private async setIndexForSearch(userId: UserId, index: SerializedLunrIndex): Promise<void> {
await this.searchIndexState(userId).update(() => index);
}
private async setIndexedEntityIdForSearch(indexedEntityId: IndexedEntityId): Promise<void> {
await this.searchIndexEntityIdState.update(() => indexedEntityId);
private async setIndexedEntityIdForSearch(
userId: UserId,
indexedEntityId: IndexedEntityId,
): Promise<void> {
await this.searchIndexEntityIdState(userId).update(() => indexedEntityId);
}
private async setIsIndexing(indexing: boolean): Promise<void> {
await this.searchIsIndexingState.update(() => indexing);
private async setIsIndexing(userId: UserId, indexing: boolean): Promise<void> {
await this.searchIsIndexingState(userId).update(() => indexing);
}
private async getIsIndexing(): Promise<boolean> {
return await firstValueFrom(this.searchIsIndexing$);
private async getIsIndexing(userId: UserId): Promise<boolean> {
return await firstValueFrom(this.searchIsIndexing$(userId));
}
private fieldExtractor(c: CipherView, joined: boolean) {

View File

@@ -1,6 +1,5 @@
import { MockProxy, mock } from "jest-mock-extended";
// eslint-disable-next-line import/no-restricted-paths -- Needed to print log messages
import { FakeStorageService } from "../../spec/fake-storage.service";
// eslint-disable-next-line import/no-restricted-paths -- Needed client type enum
import { ClientType } from "../enums";

View File

@@ -0,0 +1,136 @@
import { mock } from "jest-mock-extended";
import { BehaviorSubject, firstValueFrom } from "rxjs";
import { FakeAccountService, FakeStateProvider, awaitAsync } from "../../../spec";
import { Account } from "../../auth/abstractions/account.service";
import { EXTENSION_DISK, UserKeyDefinition } from "../../platform/state";
import { UserId } from "../../types/guid";
import { LegacyEncryptorProvider } from "../cryptography/legacy-encryptor-provider";
import { UserEncryptor } from "../cryptography/user-encryptor.abstraction";
import { disabledSemanticLoggerProvider } from "../log";
import { UserStateSubjectDependencyProvider } from "../state/user-state-subject-dependency-provider";
import { Site } from "./data";
import { ExtensionRegistry } from "./extension-registry.abstraction";
import { ExtensionSite } from "./extension-site";
import { ExtensionService } from "./extension.service";
import { ExtensionMetadata, ExtensionProfileMetadata, ExtensionStorageKey } from "./type";
import { Vendor } from "./vendor/data";
import { SimpleLogin } from "./vendor/simplelogin";
const SomeUser = "some user" as UserId;
const SomeAccount = {
id: SomeUser,
email: "someone@example.com",
emailVerified: true,
name: "Someone",
};
const SomeAccount$ = new BehaviorSubject<Account>(SomeAccount);
type TestType = { foo: string };
const SomeEncryptor: UserEncryptor = {
userId: SomeUser,
encrypt(secret) {
const tmp: any = secret;
return Promise.resolve({ foo: `encrypt(${tmp.foo})` } as any);
},
decrypt(secret) {
const tmp: any = JSON.parse(secret.encryptedString!);
return Promise.resolve({ foo: `decrypt(${tmp.foo})` } as any);
},
};
const SomeAccountService = new FakeAccountService({
[SomeUser]: SomeAccount,
});
const SomeStateProvider = new FakeStateProvider(SomeAccountService);
const SomeProvider = {
encryptor: {
userEncryptor$: () => {
return new BehaviorSubject({ encryptor: SomeEncryptor, userId: SomeUser }).asObservable();
},
organizationEncryptor$() {
throw new Error("`organizationEncryptor$` should never be invoked.");
},
} as LegacyEncryptorProvider,
state: SomeStateProvider,
log: disabledSemanticLoggerProvider,
} as UserStateSubjectDependencyProvider;
const SomeExtension: ExtensionMetadata = {
site: { id: "forwarder", availableFields: [] },
product: { vendor: SimpleLogin },
host: {
selfHost: "maybe",
baseUrl: "https://www.example.com/",
authentication: true,
},
requestedFields: [],
};
const SomeRegistry = mock<ExtensionRegistry>();
const SomeProfileMetadata = {
type: "extension",
site: Site.forwarder,
storage: {
key: "someProfile",
options: {
deserializer: (value) => value as TestType,
clearOn: [],
},
} as ExtensionStorageKey<TestType>,
} satisfies ExtensionProfileMetadata<TestType, "forwarder">;
describe("ExtensionService", () => {
beforeEach(() => {
jest.resetAllMocks();
});
describe("settings", () => {
it("writes to the user's state", async () => {
const extension = new ExtensionService(SomeRegistry, SomeProvider);
SomeRegistry.extension.mockReturnValue(SomeExtension);
const subject = extension.settings(SomeProfileMetadata, Vendor.simplelogin, {
account$: SomeAccount$,
});
subject.next({ foo: "next value" });
await awaitAsync();
// if the write succeeded, then the storage location should contain an object;
// the precise value isn't tested to avoid coupling the test to the storage format
const expectedKey = new UserKeyDefinition(
EXTENSION_DISK,
"forwarder.simplelogin.someProfile",
SomeProfileMetadata.storage.options,
);
const result = await firstValueFrom(SomeStateProvider.getUserState$(expectedKey, SomeUser));
expect(result).toBeTruthy();
});
it("panics when the extension metadata isn't available", async () => {
const extension = new ExtensionService(SomeRegistry, SomeProvider);
expect(() =>
extension.settings(SomeProfileMetadata, Vendor.bitwarden, { account$: SomeAccount$ }),
).toThrow("extension not defined");
});
});
describe("site", () => {
it("returns an extension site", () => {
const expected = new ExtensionSite(SomeExtension.site, new Map());
SomeRegistry.build.mockReturnValueOnce(expected);
const extension = new ExtensionService(SomeRegistry, SomeProvider);
const site = extension.site(Site.forwarder);
expect(site).toEqual(expected);
});
});
});

View File

@@ -0,0 +1,63 @@
import { shareReplay } from "rxjs";
import { Account } from "../../auth/abstractions/account.service";
import { BoundDependency } from "../dependencies";
import { SemanticLogger } from "../log";
import { UserStateSubject } from "../state/user-state-subject";
import { UserStateSubjectDependencyProvider } from "../state/user-state-subject-dependency-provider";
import { ExtensionRegistry } from "./extension-registry.abstraction";
import { ExtensionProfileMetadata, SiteId, VendorId } from "./type";
import { toObjectKey } from "./util";
/** Provides configuration and storage support for Bitwarden client extensions.
* These extensions integrate 3rd party services into Bitwarden.
*/
export class ExtensionService {
/** Instantiate the extension service.
* @param registry provides runtime status for extension sites
* @param providers provide persistent data
*/
constructor(
private registry: ExtensionRegistry,
private readonly providers: UserStateSubjectDependencyProvider,
) {
this.log = providers.log({
type: "ExtensionService",
});
}
private log: SemanticLogger;
/** Get a subject bound to a user's extension settings
* @param profile the site's extension profile
* @param vendor the vendor integrated at the extension site
* @param dependencies.account$ the account to which the settings are bound
* @returns a subject bound to the requested user's generator settings
*/
settings<Settings extends object, Site extends SiteId>(
profile: ExtensionProfileMetadata<Settings, Site>,
vendor: VendorId,
dependencies: BoundDependency<"account", Account>,
): UserStateSubject<Settings> {
const metadata = this.registry.extension(profile.site, vendor);
if (!metadata) {
this.log.panic({ site: profile.site as string, vendor }, "extension not defined");
}
const key = toObjectKey(profile, metadata);
const account$ = dependencies.account$.pipe(shareReplay({ bufferSize: 1, refCount: true }));
// FIXME: load and apply constraints
const subject = new UserStateSubject(key, this.providers, { account$ });
return subject;
}
/** Look up extension metadata for a site
* @param site defines the site to retrieve.
* @returns the extensions available at the site.
*/
site(site: SiteId) {
return this.registry.build(site);
}
}

View File

@@ -1,5 +1,7 @@
import { Opaque } from "type-fest";
import { ObjectKey } from "../state/object-key";
import { Site, Field, Permission } from "./data";
/** well-known name for a feature extensible through an extension. */
@@ -17,6 +19,11 @@ export type ExtensionId = { site: SiteId; vendor: VendorId };
/** Permission levels for metadata. */
export type ExtensionPermission = keyof typeof Permission;
/** The preferred vendor to use at each site. */
export type ExtensionPreferences = {
[key in SiteId]?: { vendor: VendorId; updated: Date };
};
/** The capabilities and descriptive content for an extension */
export type SiteMetadata = {
/** Uniquely identifies the extension site. */
@@ -107,3 +114,29 @@ export type ExtensionSet =
*/
all: true;
};
/** A key for storing JavaScript objects (`{ an: "example" }`)
* in the extension profile system.
* @remarks The omitted keys are filled by the extension service.
*/
export type ExtensionStorageKey<Options> = Omit<
ObjectKey<Options>,
"target" | "state" | "format" | "classifier"
>;
/** Extension profiles encapsulate data storage using the extension system.
*/
export type ExtensionProfileMetadata<Options, Site extends SiteId> = {
/** distinguishes profile metadata types */
type: "extension";
/** The extension site described by this metadata */
site: Site;
/** persistent storage location; `storage.key` is used to construct
* the extension key in the format `${extension.site}.${extension.vendor}.${storage.key}`,
* where `extension.`-prefixed fields are read from extension metadata. Extension
* settings always use the "classified" format and keep all fields private.
*/
storage: ExtensionStorageKey<Options>;
};

View File

@@ -0,0 +1,54 @@
import { EXTENSION_DISK } from "../../platform/state";
import { PrivateClassifier } from "../private-classifier";
import { deepFreeze } from "../util";
import { Site } from "./data";
import { ExtensionMetadata, ExtensionProfileMetadata } from "./type";
import { toObjectKey } from "./util";
import { Bitwarden } from "./vendor/bitwarden";
const ExampleProfile: ExtensionProfileMetadata<object, "forwarder"> = deepFreeze({
type: "extension",
site: "forwarder",
storage: {
key: "example",
options: {
clearOn: [],
deserializer: (value) => value as any,
},
initial: {},
frame: 1,
},
});
const ExampleMetadata: ExtensionMetadata = {
site: { id: Site.forwarder, availableFields: [] },
product: { vendor: Bitwarden },
host: { authentication: true, selfHost: "maybe", baseUrl: "http://example.com" },
requestedFields: [],
};
describe("toObjectKey", () => {
it("sets static fields", () => {
const result = toObjectKey(ExampleProfile, ExampleMetadata);
expect(result.target).toEqual("object");
expect(result.format).toEqual("classified");
expect(result.state).toBe(EXTENSION_DISK);
expect(result.classifier).toBeInstanceOf(PrivateClassifier);
});
it("creates a dynamic object key", () => {
const result = toObjectKey(ExampleProfile, ExampleMetadata);
expect(result.key).toEqual("forwarder.bitwarden.example");
});
it("copies the profile storage metadata", () => {
const result = toObjectKey(ExampleProfile, ExampleMetadata);
expect(result.frame).toEqual(ExampleProfile.storage.frame);
expect(result.options).toBe(ExampleProfile.storage.options);
expect(result.initial).toBe(ExampleProfile.storage.initial);
});
});

View File

@@ -0,0 +1,36 @@
import { EXTENSION_DISK } from "../../platform/state";
import { PrivateClassifier } from "../private-classifier";
import { Classifier } from "../state/classifier";
import { ObjectKey } from "../state/object-key";
import { ExtensionMetadata, ExtensionProfileMetadata, SiteId } from "./type";
/** Create an object key from an extension instance and a site profile.
* @param profile the extension profile to bind
* @param extension the extension metadata to bind
*/
export function toObjectKey<Settings extends object, Site extends SiteId>(
profile: ExtensionProfileMetadata<Settings, Site>,
extension: ExtensionMetadata,
) {
// FIXME: eliminate this cast
const classifier = new PrivateClassifier<Settings>() as Classifier<
Settings,
Record<string, never>,
Settings
>;
const result: ObjectKey<Settings> = {
// copy storage to retain extensibility
...profile.storage,
// fields controlled by the extension system override those in the profile
target: "object",
key: `${extension.site.id}.${extension.product.vendor.id}.${profile.storage.key}`,
state: EXTENSION_DISK,
classifier,
format: "classified",
};
return result;
}

View File

@@ -10,4 +10,4 @@ export const IntegrationIds = [
] as const;
/** Identifies a vendor integrated into bitwarden */
export type IntegrationId = Opaque<(typeof IntegrationIds)[number], "IntegrationId">;
export type IntegrationId = Opaque<string, "IntegrationId">;

View File

@@ -14,33 +14,36 @@ describe("DefaultSemanticLogger", () => {
describe("debug", () => {
it("writes structural log messages to console.log", () => {
const log = new DefaultSemanticLogger(logger, {});
const log = new DefaultSemanticLogger(logger, {}, () => 0);
log.debug("this is a debug message");
expect(logger.write).toHaveBeenCalledWith(LogLevelType.Debug, {
"@timestamp": 0,
message: "this is a debug message",
level: "debug",
});
});
it("writes structural content to console.log", () => {
const log = new DefaultSemanticLogger(logger, {});
const log = new DefaultSemanticLogger(logger, {}, () => 0);
log.debug({ example: "this is content" });
expect(logger.write).toHaveBeenCalledWith(LogLevelType.Debug, {
"@timestamp": 0,
content: { example: "this is content" },
level: "debug",
});
});
it("writes structural content to console.log with a message", () => {
const log = new DefaultSemanticLogger(logger, {});
const log = new DefaultSemanticLogger(logger, {}, () => 0);
log.info({ example: "this is content" }, "this is a message");
expect(logger.write).toHaveBeenCalledWith(LogLevelType.Info, {
"@timestamp": 0,
content: { example: "this is content" },
message: "this is a message",
level: "information",
@@ -50,33 +53,36 @@ describe("DefaultSemanticLogger", () => {
describe("info", () => {
it("writes structural log messages to console.log", () => {
const log = new DefaultSemanticLogger(logger, {});
const log = new DefaultSemanticLogger(logger, {}, () => 0);
log.info("this is an info message");
expect(logger.write).toHaveBeenCalledWith(LogLevelType.Info, {
"@timestamp": 0,
message: "this is an info message",
level: "information",
});
});
it("writes structural content to console.log", () => {
const log = new DefaultSemanticLogger(logger, {});
const log = new DefaultSemanticLogger(logger, {}, () => 0);
log.info({ example: "this is content" });
expect(logger.write).toHaveBeenCalledWith(LogLevelType.Info, {
"@timestamp": 0,
content: { example: "this is content" },
level: "information",
});
});
it("writes structural content to console.log with a message", () => {
const log = new DefaultSemanticLogger(logger, {});
const log = new DefaultSemanticLogger(logger, {}, () => 0);
log.info({ example: "this is content" }, "this is a message");
expect(logger.write).toHaveBeenCalledWith(LogLevelType.Info, {
"@timestamp": 0,
content: { example: "this is content" },
message: "this is a message",
level: "information",
@@ -86,33 +92,36 @@ describe("DefaultSemanticLogger", () => {
describe("warn", () => {
it("writes structural log messages to console.warn", () => {
const log = new DefaultSemanticLogger(logger, {});
const log = new DefaultSemanticLogger(logger, {}, () => 0);
log.warn("this is a warning message");
expect(logger.write).toHaveBeenCalledWith(LogLevelType.Warning, {
"@timestamp": 0,
message: "this is a warning message",
level: "warning",
});
});
it("writes structural content to console.warn", () => {
const log = new DefaultSemanticLogger(logger, {});
const log = new DefaultSemanticLogger(logger, {}, () => 0);
log.warn({ example: "this is content" });
expect(logger.write).toHaveBeenCalledWith(LogLevelType.Warning, {
"@timestamp": 0,
content: { example: "this is content" },
level: "warning",
});
});
it("writes structural content to console.warn with a message", () => {
const log = new DefaultSemanticLogger(logger, {});
const log = new DefaultSemanticLogger(logger, {}, () => 0);
log.warn({ example: "this is content" }, "this is a message");
expect(logger.write).toHaveBeenCalledWith(LogLevelType.Warning, {
"@timestamp": 0,
content: { example: "this is content" },
message: "this is a message",
level: "warning",
@@ -122,33 +131,36 @@ describe("DefaultSemanticLogger", () => {
describe("error", () => {
it("writes structural log messages to console.error", () => {
const log = new DefaultSemanticLogger(logger, {});
const log = new DefaultSemanticLogger(logger, {}, () => 0);
log.error("this is an error message");
expect(logger.write).toHaveBeenCalledWith(LogLevelType.Error, {
"@timestamp": 0,
message: "this is an error message",
level: "error",
});
});
it("writes structural content to console.error", () => {
const log = new DefaultSemanticLogger(logger, {});
const log = new DefaultSemanticLogger(logger, {}, () => 0);
log.error({ example: "this is content" });
expect(logger.write).toHaveBeenCalledWith(LogLevelType.Error, {
"@timestamp": 0,
content: { example: "this is content" },
level: "error",
});
});
it("writes structural content to console.error with a message", () => {
const log = new DefaultSemanticLogger(logger, {});
const log = new DefaultSemanticLogger(logger, {}, () => 0);
log.error({ example: "this is content" }, "this is a message");
expect(logger.write).toHaveBeenCalledWith(LogLevelType.Error, {
"@timestamp": 0,
content: { example: "this is content" },
message: "this is a message",
level: "error",
@@ -158,24 +170,26 @@ describe("DefaultSemanticLogger", () => {
describe("panic", () => {
it("writes structural log messages to console.error before throwing the message", () => {
const log = new DefaultSemanticLogger(logger, {});
const log = new DefaultSemanticLogger(logger, {}, () => 0);
expect(() => log.panic("this is an error message")).toThrow("this is an error message");
expect(logger.write).toHaveBeenCalledWith(LogLevelType.Error, {
"@timestamp": 0,
message: "this is an error message",
level: "error",
});
});
it("writes structural log messages to console.error with a message before throwing the message", () => {
const log = new DefaultSemanticLogger(logger, {});
const log = new DefaultSemanticLogger(logger, {}, () => 0);
expect(() => log.panic({ example: "this is content" }, "this is an error message")).toThrow(
"this is an error message",
);
expect(logger.write).toHaveBeenCalledWith(LogLevelType.Error, {
"@timestamp": 0,
content: { example: "this is content" },
message: "this is an error message",
level: "error",
@@ -183,13 +197,14 @@ describe("DefaultSemanticLogger", () => {
});
it("writes structural log messages to console.error with a content before throwing the message", () => {
const log = new DefaultSemanticLogger(logger, {});
const log = new DefaultSemanticLogger(logger, {}, () => 0);
expect(() => log.panic("this is content", "this is an error message")).toThrow(
"this is an error message",
);
expect(logger.write).toHaveBeenCalledWith(LogLevelType.Error, {
"@timestamp": 0,
content: "this is content",
message: "this is an error message",
level: "error",

View File

@@ -18,6 +18,7 @@ export class DefaultSemanticLogger<Context extends object> implements SemanticLo
constructor(
private logger: LogService,
context: Jsonify<Context>,
private now = () => Date.now(),
) {
this.context = context && typeof context === "object" ? context : {};
}
@@ -53,6 +54,7 @@ export class DefaultSemanticLogger<Context extends object> implements SemanticLo
message,
content: content ?? undefined,
level: stringifyLevel(level),
"@timestamp": this.now(),
};
if (typeof content === "string" && !message) {

View File

@@ -17,7 +17,7 @@ export class PrivateClassifier<Data> implements Classifier<Data, Record<string,
}
const secret = picked as Jsonify<Data>;
return { disclosed: null, secret };
return { disclosed: {}, secret };
}
declassify(_disclosed: Jsonify<Record<keyof Data, never>>, secret: Jsonify<Data>) {

View File

@@ -0,0 +1,16 @@
import { PolicyService } from "../admin-console/abstractions/policy/policy.service.abstraction";
import { ExtensionService } from "./extension/extension.service";
import { LogProvider } from "./log";
/** Provides access to commonly-used cross-cutting services. */
export type SystemServiceProvider = {
/** Policy configured by the administrative console */
readonly policy: PolicyService;
/** Client extension metadata and profile access */
readonly extension: ExtensionService;
/** Event monitoring and diagnostic interfaces */
readonly log: LogProvider;
};

View File

@@ -16,7 +16,7 @@ export class PublicClassifier<Data> implements Classifier<Data, Data, Record<str
}
const disclosed = picked as Jsonify<Data>;
return { disclosed, secret: null };
return { disclosed, secret: "" };
}
declassify(disclosed: Jsonify<Data>, _secret: Jsonify<Record<keyof Data, never>>) {

View File

@@ -54,14 +54,7 @@ export class SendAccess extends Domain {
async decrypt(key: SymmetricCryptoKey): Promise<SendAccessView> {
const model = new SendAccessView(this);
await this.decryptObj(
model,
{
name: null,
},
null,
key,
);
await this.decryptObj<SendAccess, SendAccessView>(this, model, ["name"], null, key);
switch (this.type) {
case SendType.File:

View File

@@ -34,15 +34,13 @@ export class SendFile extends Domain {
}
async decrypt(key: SymmetricCryptoKey): Promise<SendFileView> {
const view = await this.decryptObj(
return await this.decryptObj<SendFile, SendFileView>(
this,
new SendFileView(this),
{
fileName: null,
},
["fileName"],
null,
key,
);
return view;
}
static fromJSON(obj: Jsonify<SendFile>) {

View File

@@ -30,11 +30,10 @@ export class SendText extends Domain {
}
decrypt(key: SymmetricCryptoKey): Promise<SendTextView> {
return this.decryptObj(
return this.decryptObj<SendText, SendTextView>(
this,
new SendTextView(this),
{
text: null,
},
["text"],
null,
key,
);

View File

@@ -87,15 +87,7 @@ export class Send extends Domain {
// TODO: error?
}
await this.decryptObj(
model,
{
name: null,
notes: null,
},
null,
model.cryptoKey,
);
await this.decryptObj<Send, SendView>(this, model, ["name", "notes"], null, model.cryptoKey);
switch (this.type) {
case SendType.File:

View File

@@ -0,0 +1,27 @@
import { isClassifiedFormat } from "./classified-format";
describe("isClassifiedFormat", () => {
it("returns `false` when the argument is `null`", () => {
expect(isClassifiedFormat(null)).toEqual(false);
});
it.each([
[{ id: true, secret: "" }],
[{ secret: "", disclosed: {} }],
[{ id: true, disclosed: {} }],
])("returns `false` when the argument is missing a required member (=%p).", (value) => {
expect(isClassifiedFormat(value)).toEqual(false);
});
it("returns `false` when 'secret' is not a string", () => {
expect(isClassifiedFormat({ id: true, secret: false, disclosed: {} })).toEqual(false);
});
it("returns `false` when 'disclosed' is not an object", () => {
expect(isClassifiedFormat({ id: true, secret: "", disclosed: false })).toEqual(false);
});
it("returns `true` when the argument has a `secret`, `disclosed`, and `id`.", () => {
expect(isClassifiedFormat({ id: true, secret: "", disclosed: {} })).toEqual(true);
});
});

View File

@@ -21,5 +21,12 @@ export type ClassifiedFormat<Id, Disclosed> = {
export function isClassifiedFormat<Id, Disclosed>(
value: any,
): value is ClassifiedFormat<Id, Disclosed> {
return "id" in value && "secret" in value && "disclosed" in value;
return (
!!value &&
"id" in value &&
"secret" in value &&
"disclosed" in value &&
typeof value.secret === "string" &&
typeof value.disclosed === "object"
);
}

View File

@@ -523,6 +523,7 @@ export class UserStateSubject<
private onError(value: any) {
if (!this.isDisposed) {
this.log.debug(value, "forwarding error to subscribers");
this.output.error(value);
}

View File

@@ -11,3 +11,4 @@ export type CipherId = Opaque<string, "CipherId">;
export type SendId = Opaque<string, "SendId">;
export type IndexedEntityId = Opaque<string, "IndexedEntityId">;
export type SecurityTaskId = Opaque<string, "SecurityTaskId">;
export type NotificationId = Opaque<string, "NotificationId">;

View File

@@ -1,6 +1,15 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Observable } from "rxjs";
import { TotpResponse } from "@bitwarden/sdk-internal";
export abstract class TotpService {
getCode: (key: string) => Promise<string>;
getTimeInterval: (key: string) => number;
/**
* Gets an observable that emits TOTP codes at regular intervals
* @param key - Can be:
* - A base32 encoded string
* - OTP Auth URI
* - Steam URI
* @returns Observable that emits TotpResponse containing the code and period
*/
abstract getCode$(key: string): Observable<TotpResponse>;
}

View File

@@ -89,7 +89,7 @@ describe("buildCipherIcon", () => {
expect(iconDetails).toEqual({
icon: "bwi-globe",
image: undefined,
image: null,
fallbackImage: "",
imageEnabled: false,
});
@@ -102,7 +102,7 @@ describe("buildCipherIcon", () => {
expect(iconDetails).toEqual({
icon: "bwi-globe",
image: undefined,
image: null,
fallbackImage: "",
imageEnabled: true,
});

View File

@@ -2,9 +2,23 @@ import { Utils } from "../../platform/misc/utils";
import { CipherType } from "../enums/cipher-type";
import { CipherView } from "../models/view/cipher.view";
export function buildCipherIcon(iconsServerUrl: string, cipher: CipherView, showFavicon: boolean) {
let icon;
let image;
export interface CipherIconDetails {
imageEnabled: boolean;
image: string | null;
/**
* @deprecated Fallback to `icon` instead which will default to "bwi-globe" if no other icon is applicable.
*/
fallbackImage: string;
icon: string;
}
export function buildCipherIcon(
iconsServerUrl: string | null,
cipher: CipherView,
showFavicon: boolean,
): CipherIconDetails {
let icon: string = "bwi-globe";
let image: string | null = null;
let fallbackImage = "";
const cardIcons: Record<string, string> = {
Visa: "card-visa",
@@ -18,6 +32,10 @@ export function buildCipherIcon(iconsServerUrl: string, cipher: CipherView, show
RuPay: "card-ru-pay",
};
if (iconsServerUrl == null) {
showFavicon = false;
}
switch (cipher.type) {
case CipherType.Login:
icon = "bwi-globe";
@@ -53,9 +71,7 @@ export function buildCipherIcon(iconsServerUrl: string, cipher: CipherView, show
try {
image = `${iconsServerUrl}/${Utils.getHostname(hostnameUri)}/icon.png`;
fallbackImage = "images/bwi-globe.png";
// FIXME: Remove when updating file. Eslint update
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (e) {
} catch {
// Ignore error since the fallback icon will be shown if image is null.
}
}

View File

@@ -0,0 +1,21 @@
import { Jsonify } from "type-fest";
import { BaseResponse } from "../../../models/response/base.response";
export class CipherPermissionsApi extends BaseResponse {
delete: boolean = false;
restore: boolean = false;
constructor(data: any = null) {
super(data);
if (data == null) {
return;
}
this.delete = this.getResponseProperty("Delete");
this.restore = this.getResponseProperty("Restore");
}
static fromJSON(obj: Jsonify<CipherPermissionsApi>) {
return Object.assign(new CipherPermissionsApi(), obj);
}
}

View File

@@ -4,6 +4,7 @@ import { Jsonify } from "type-fest";
import { CipherRepromptType } from "../../enums/cipher-reprompt-type";
import { CipherType } from "../../enums/cipher-type";
import { CipherPermissionsApi } from "../api/cipher-permissions.api";
import { CipherResponse } from "../response/cipher.response";
import { AttachmentData } from "./attachment.data";
@@ -21,6 +22,7 @@ export class CipherData {
folderId: string;
edit: boolean;
viewPassword: boolean;
permissions: CipherPermissionsApi;
organizationUseTotp: boolean;
favorite: boolean;
revisionDate: string;
@@ -51,6 +53,7 @@ export class CipherData {
this.folderId = response.folderId;
this.edit = response.edit;
this.viewPassword = response.viewPassword;
this.permissions = response.permissions;
this.organizationUseTotp = response.organizationUseTotp;
this.favorite = response.favorite;
this.revisionDate = response.revisionDate;
@@ -95,6 +98,8 @@ export class CipherData {
}
static fromJSON(obj: Jsonify<CipherData>) {
return Object.assign(new CipherData(), obj);
const result = Object.assign(new CipherData(), obj);
result.permissions = CipherPermissionsApi.fromJSON(obj.permissions);
return result;
}
}

View File

@@ -43,11 +43,10 @@ export class Attachment extends Domain {
context = "No Cipher Context",
encKey?: SymmetricCryptoKey,
): Promise<AttachmentView> {
const view = await this.decryptObj(
const view = await this.decryptObj<Attachment, AttachmentView>(
this,
new AttachmentView(this),
{
fileName: null,
},
["fileName"],
orgId,
encKey,
"DomainType: Attachment; " + context,

View File

@@ -42,16 +42,10 @@ export class Card extends Domain {
context = "No Cipher Context",
encKey?: SymmetricCryptoKey,
): Promise<CardView> {
return this.decryptObj(
return this.decryptObj<Card, CardView>(
this,
new CardView(),
{
cardholderName: null,
brand: null,
number: null,
expMonth: null,
expYear: null,
code: null,
},
["cardholderName", "brand", "number", "expMonth", "expYear", "code"],
orgId,
encKey,
"DomainType: Card; " + context,

View File

@@ -26,6 +26,7 @@ import { SecureNote } from "../../models/domain/secure-note";
import { CardView } from "../../models/view/card.view";
import { IdentityView } from "../../models/view/identity.view";
import { LoginView } from "../../models/view/login.view";
import { CipherPermissionsApi } from "../api/cipher-permissions.api";
describe("Cipher DTO", () => {
it("Convert from empty CipherData", () => {
@@ -54,6 +55,7 @@ describe("Cipher DTO", () => {
fields: null,
passwordHistory: null,
key: null,
permissions: undefined,
});
});
@@ -75,6 +77,7 @@ describe("Cipher DTO", () => {
notes: "EncryptedString",
creationDate: "2022-01-01T12:00:00.000Z",
deletedDate: null,
permissions: new CipherPermissionsApi(),
reprompt: CipherRepromptType.None,
key: "EncryptedString",
login: {
@@ -149,6 +152,7 @@ describe("Cipher DTO", () => {
localData: null,
creationDate: new Date("2022-01-01T12:00:00.000Z"),
deletedDate: null,
permissions: new CipherPermissionsApi(),
reprompt: 0,
key: { encryptedString: "EncryptedString", encryptionType: 0 },
login: {
@@ -228,6 +232,7 @@ describe("Cipher DTO", () => {
cipher.deletedDate = null;
cipher.reprompt = CipherRepromptType.None;
cipher.key = mockEnc("EncKey");
cipher.permissions = new CipherPermissionsApi();
const loginView = new LoginView();
loginView.username = "username";
@@ -270,6 +275,7 @@ describe("Cipher DTO", () => {
deletedDate: null,
reprompt: 0,
localData: undefined,
permissions: new CipherPermissionsApi(),
});
});
});
@@ -297,6 +303,7 @@ describe("Cipher DTO", () => {
secureNote: {
type: SecureNoteType.Generic,
},
permissions: new CipherPermissionsApi(),
};
});
@@ -326,6 +333,7 @@ describe("Cipher DTO", () => {
fields: null,
passwordHistory: null,
key: { encryptedString: "EncKey", encryptionType: 0 },
permissions: new CipherPermissionsApi(),
});
});
@@ -353,6 +361,7 @@ describe("Cipher DTO", () => {
cipher.secureNote = new SecureNote();
cipher.secureNote.type = SecureNoteType.Generic;
cipher.key = mockEnc("EncKey");
cipher.permissions = new CipherPermissionsApi();
const keyService = mock<KeyService>();
const encryptService = mock<EncryptService>();
@@ -387,6 +396,7 @@ describe("Cipher DTO", () => {
deletedDate: null,
reprompt: 0,
localData: undefined,
permissions: new CipherPermissionsApi(),
});
});
});
@@ -409,6 +419,7 @@ describe("Cipher DTO", () => {
notes: "EncryptedString",
creationDate: "2022-01-01T12:00:00.000Z",
deletedDate: null,
permissions: new CipherPermissionsApi(),
reprompt: CipherRepromptType.None,
card: {
cardholderName: "EncryptedString",
@@ -455,6 +466,7 @@ describe("Cipher DTO", () => {
fields: null,
passwordHistory: null,
key: { encryptedString: "EncKey", encryptionType: 0 },
permissions: new CipherPermissionsApi(),
});
});
@@ -480,6 +492,7 @@ describe("Cipher DTO", () => {
cipher.deletedDate = null;
cipher.reprompt = CipherRepromptType.None;
cipher.key = mockEnc("EncKey");
cipher.permissions = new CipherPermissionsApi();
const cardView = new CardView();
cardView.cardholderName = "cardholderName";
@@ -522,6 +535,7 @@ describe("Cipher DTO", () => {
deletedDate: null,
reprompt: 0,
localData: undefined,
permissions: new CipherPermissionsApi(),
});
});
});
@@ -544,6 +558,7 @@ describe("Cipher DTO", () => {
notes: "EncryptedString",
creationDate: "2022-01-01T12:00:00.000Z",
deletedDate: null,
permissions: new CipherPermissionsApi(),
reprompt: CipherRepromptType.None,
key: "EncKey",
identity: {
@@ -614,6 +629,7 @@ describe("Cipher DTO", () => {
fields: null,
passwordHistory: null,
key: { encryptedString: "EncKey", encryptionType: 0 },
permissions: new CipherPermissionsApi(),
});
});
@@ -639,6 +655,7 @@ describe("Cipher DTO", () => {
cipher.deletedDate = null;
cipher.reprompt = CipherRepromptType.None;
cipher.key = mockEnc("EncKey");
cipher.permissions = new CipherPermissionsApi();
const identityView = new IdentityView();
identityView.firstName = "firstName";
@@ -681,6 +698,7 @@ describe("Cipher DTO", () => {
deletedDate: null,
reprompt: 0,
localData: undefined,
permissions: new CipherPermissionsApi(),
});
});
});

View File

@@ -10,6 +10,7 @@ import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-cr
import { InitializerKey } from "../../../platform/services/cryptography/initializer-key";
import { CipherRepromptType } from "../../enums/cipher-reprompt-type";
import { CipherType } from "../../enums/cipher-type";
import { CipherPermissionsApi } from "../api/cipher-permissions.api";
import { CipherData } from "../data/cipher.data";
import { LocalData } from "../data/local.data";
import { AttachmentView } from "../view/attachment.view";
@@ -39,6 +40,7 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
organizationUseTotp: boolean;
edit: boolean;
viewPassword: boolean;
permissions: CipherPermissionsApi;
revisionDate: Date;
localData: LocalData;
login: Login;
@@ -84,6 +86,7 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
} else {
this.viewPassword = true; // Default for already synced Ciphers without viewPassword
}
this.permissions = obj.permissions;
this.revisionDate = obj.revisionDate != null ? new Date(obj.revisionDate) : null;
this.collectionIds = obj.collectionIds;
this.localData = localData;
@@ -154,12 +157,10 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
bypassValidation = false;
}
await this.decryptObj(
await this.decryptObj<Cipher, CipherView>(
this,
model,
{
name: null,
notes: null,
},
["name", "notes"],
this.organizationId,
encKey,
);
@@ -246,6 +247,7 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
c.deletedDate = this.deletedDate != null ? this.deletedDate.toISOString() : null;
c.reprompt = this.reprompt;
c.key = this.key?.encryptedString;
c.permissions = this.permissions;
this.buildDataModel(this, c, {
name: null,

View File

@@ -52,41 +52,38 @@ export class Fido2Credential extends Domain {
}
async decrypt(orgId: string, encKey?: SymmetricCryptoKey): Promise<Fido2CredentialView> {
const view = await this.decryptObj(
const view = await this.decryptObj<Fido2Credential, Fido2CredentialView>(
this,
new Fido2CredentialView(),
{
credentialId: null,
keyType: null,
keyAlgorithm: null,
keyCurve: null,
keyValue: null,
rpId: null,
userHandle: null,
userName: null,
rpName: null,
userDisplayName: null,
discoverable: null,
},
[
"credentialId",
"keyType",
"keyAlgorithm",
"keyCurve",
"keyValue",
"rpId",
"userHandle",
"userName",
"rpName",
"userDisplayName",
],
orgId,
encKey,
);
const { counter } = await this.decryptObj(
{ counter: "" },
const { counter } = await this.decryptObj<
Fido2Credential,
{
counter: null,
},
orgId,
encKey,
);
counter: string;
}
>(this, { counter: "" }, ["counter"], orgId, encKey);
// Counter will end up as NaN if this fails
view.counter = parseInt(counter);
const { discoverable } = await this.decryptObj(
const { discoverable } = await this.decryptObj<Fido2Credential, { discoverable: string }>(
this,
{ discoverable: "" },
{
discoverable: null,
},
["discoverable"],
orgId,
encKey,
);

View File

@@ -35,12 +35,10 @@ export class Field extends Domain {
}
decrypt(orgId: string, encKey?: SymmetricCryptoKey): Promise<FieldView> {
return this.decryptObj(
return this.decryptObj<Field, FieldView>(
this,
new FieldView(this),
{
name: null,
value: null,
},
["name", "value"],
orgId,
encKey,
);

View File

@@ -40,13 +40,7 @@ export class Folder extends Domain {
}
decrypt(): Promise<FolderView> {
return this.decryptObj(
new FolderView(this),
{
name: null,
},
null,
);
return this.decryptObj<Folder, FolderView>(this, new FolderView(this), ["name"], null);
}
async decryptWithKey(

View File

@@ -66,28 +66,29 @@ export class Identity extends Domain {
context: string = "No Cipher Context",
encKey?: SymmetricCryptoKey,
): Promise<IdentityView> {
return this.decryptObj(
return this.decryptObj<Identity, IdentityView>(
this,
new IdentityView(),
{
title: null,
firstName: null,
middleName: null,
lastName: null,
address1: null,
address2: null,
address3: null,
city: null,
state: null,
postalCode: null,
country: null,
company: null,
email: null,
phone: null,
ssn: null,
username: null,
passportNumber: null,
licenseNumber: null,
},
[
"title",
"firstName",
"middleName",
"lastName",
"address1",
"address2",
"address3",
"city",
"state",
"postalCode",
"country",
"company",
"email",
"phone",
"ssn",
"username",
"passportNumber",
"licenseNumber",
],
orgId,
encKey,
"DomainType: Identity; " + context,

View File

@@ -38,11 +38,10 @@ export class LoginUri extends Domain {
context: string = "No Cipher Context",
encKey?: SymmetricCryptoKey,
): Promise<LoginUriView> {
return this.decryptObj(
return this.decryptObj<LoginUri, LoginUriView>(
this,
new LoginUriView(this),
{
uri: null,
},
["uri"],
orgId,
encKey,
context,

View File

@@ -58,13 +58,10 @@ export class Login extends Domain {
context: string = "No Cipher Context",
encKey?: SymmetricCryptoKey,
): Promise<LoginView> {
const view = await this.decryptObj(
const view = await this.decryptObj<Login, LoginView>(
this,
new LoginView(this),
{
username: null,
password: null,
totp: null,
},
["username", "password", "totp"],
orgId,
encKey,
`DomainType: Login; ${context}`,

View File

@@ -25,11 +25,10 @@ export class Password extends Domain {
}
decrypt(orgId: string, encKey?: SymmetricCryptoKey): Promise<PasswordHistoryView> {
return this.decryptObj(
return this.decryptObj<Password, PasswordHistoryView>(
this,
new PasswordHistoryView(this),
{
password: null,
},
["password"],
orgId,
encKey,
"DomainType: PasswordHistory",

View File

@@ -36,13 +36,10 @@ export class SshKey extends Domain {
context = "No Cipher Context",
encKey?: SymmetricCryptoKey,
): Promise<SshKeyView> {
return this.decryptObj(
return this.decryptObj<SshKey, SshKeyView>(
this,
new SshKeyView(),
{
privateKey: null,
publicKey: null,
keyFingerprint: null,
},
["privateKey", "publicKey", "keyFingerprint"],
orgId,
encKey,
"DomainType: SshKey; " + context,

View File

@@ -3,6 +3,7 @@
import { BaseResponse } from "../../../models/response/base.response";
import { CipherRepromptType } from "../../enums/cipher-reprompt-type";
import { CardApi } from "../api/card.api";
import { CipherPermissionsApi } from "../api/cipher-permissions.api";
import { FieldApi } from "../api/field.api";
import { IdentityApi } from "../api/identity.api";
import { LoginApi } from "../api/login.api";
@@ -28,6 +29,7 @@ export class CipherResponse extends BaseResponse {
favorite: boolean;
edit: boolean;
viewPassword: boolean;
permissions: CipherPermissionsApi;
organizationUseTotp: boolean;
revisionDate: string;
attachments: AttachmentResponse[];
@@ -53,6 +55,7 @@ export class CipherResponse extends BaseResponse {
} else {
this.viewPassword = this.getResponseProperty("ViewPassword");
}
this.permissions = new CipherPermissionsApi(this.getResponseProperty("Permissions"));
this.organizationUseTotp = this.getResponseProperty("OrganizationUseTotp");
this.revisionDate = this.getResponseProperty("RevisionDate");
this.collectionIds = this.getResponseProperty("CollectionIds");

View File

@@ -6,6 +6,7 @@ import { InitializerKey } from "../../../platform/services/cryptography/initiali
import { DeepJsonify } from "../../../types/deep-jsonify";
import { CipherType, LinkedIdType } from "../../enums";
import { CipherRepromptType } from "../../enums/cipher-reprompt-type";
import { CipherPermissionsApi } from "../api/cipher-permissions.api";
import { LocalData } from "../data/local.data";
import { Cipher } from "../domain/cipher";
@@ -29,6 +30,7 @@ export class CipherView implements View, InitializerMetadata {
type: CipherType = null;
favorite = false;
organizationUseTotp = false;
permissions: CipherPermissionsApi = new CipherPermissionsApi();
edit = false;
viewPassword = true;
localData: LocalData;
@@ -63,6 +65,7 @@ export class CipherView implements View, InitializerMetadata {
this.organizationUseTotp = c.organizationUseTotp;
this.edit = c.edit;
this.viewPassword = c.viewPassword;
this.permissions = c.permissions;
this.type = c.type;
this.localData = c.localData;
this.collectionIds = c.collectionIds;

View File

@@ -8,6 +8,8 @@ import { Utils } from "@bitwarden/common/platform/misc/utils";
import { CollectionId, UserId } from "@bitwarden/common/types/guid";
import { FakeAccountService, mockAccountServiceWith } from "../../../spec";
import { ConfigService } from "../../platform/abstractions/config/config.service";
import { CipherPermissionsApi } from "../models/api/cipher-permissions.api";
import { CipherView } from "../models/view/cipher.view";
import {
@@ -20,6 +22,7 @@ describe("CipherAuthorizationService", () => {
const mockCollectionService = mock<CollectionService>();
const mockOrganizationService = mock<OrganizationService>();
const mockConfigService = mock<ConfigService>();
const mockUserId = Utils.newGuid() as UserId;
let mockAccountService: FakeAccountService;
@@ -28,10 +31,12 @@ describe("CipherAuthorizationService", () => {
organizationId: string | null,
collectionIds: string[],
edit: boolean = true,
permissions: CipherPermissionsApi = new CipherPermissionsApi(),
) => ({
organizationId,
collectionIds,
edit,
permissions,
});
const createMockCollection = (id: string, manage: boolean) => ({
@@ -63,7 +68,78 @@ describe("CipherAuthorizationService", () => {
mockCollectionService,
mockOrganizationService,
mockAccountService,
mockConfigService,
);
mockConfigService.getFeatureFlag$.mockReturnValue(of(false));
});
describe("canRestoreCipher$", () => {
it("should return true if isAdminConsoleAction and cipher is unassigned", (done) => {
const cipher = createMockCipher("org1", []) as CipherView;
const organization = createMockOrganization({ canEditUnassignedCiphers: true });
mockOrganizationService.organizations$.mockReturnValue(
of([organization]) as Observable<Organization[]>,
);
cipherAuthorizationService.canRestoreCipher$(cipher, true).subscribe((result) => {
expect(result).toBe(true);
done();
});
});
it("should return true if isAdminConsleAction and user can edit all ciphers in the org", (done) => {
const cipher = createMockCipher("org1", ["col1"]) as CipherView;
const organization = createMockOrganization({ canEditAllCiphers: true });
mockOrganizationService.organizations$.mockReturnValue(
of([organization]) as Observable<Organization[]>,
);
cipherAuthorizationService.canRestoreCipher$(cipher, true).subscribe((result) => {
expect(result).toBe(true);
expect(mockOrganizationService.organizations$).toHaveBeenCalledWith(mockUserId);
done();
});
});
it("should return false if isAdminConsoleAction is true but user does not have permission to edit unassigned ciphers", (done) => {
const cipher = createMockCipher("org1", []) as CipherView;
const organization = createMockOrganization({ canEditUnassignedCiphers: false });
mockOrganizationService.organizations$.mockReturnValue(of([organization] as Organization[]));
cipherAuthorizationService.canRestoreCipher$(cipher, true).subscribe((result) => {
expect(result).toBe(false);
done();
});
});
it("should return false if cipher.permission.restore is false and is not an admin action", (done) => {
const cipher = createMockCipher("org1", [], true, {
restore: false,
} as CipherPermissionsApi) as CipherView;
const organization = createMockOrganization();
mockOrganizationService.organizations$.mockReturnValue(of([organization] as Organization[]));
cipherAuthorizationService.canRestoreCipher$(cipher, false).subscribe((result) => {
expect(result).toBe(false);
expect(mockCollectionService.decryptedCollectionViews$).not.toHaveBeenCalled();
done();
});
});
it("should return true if cipher.permission.restore is true and is not an admin action", (done) => {
const cipher = createMockCipher("org1", [], true, {
restore: true,
} as CipherPermissionsApi) as CipherView;
const organization = createMockOrganization();
mockOrganizationService.organizations$.mockReturnValue(of([organization] as Organization[]));
cipherAuthorizationService.canRestoreCipher$(cipher, false).subscribe((result) => {
expect(result).toBe(true);
expect(mockCollectionService.decryptedCollectionViews$).not.toHaveBeenCalled();
done();
});
});
});
describe("canDeleteCipher$", () => {
@@ -213,6 +289,34 @@ describe("CipherAuthorizationService", () => {
done();
});
});
it("should return true if feature flag enabled and cipher.permissions.delete is true", (done) => {
const cipher = createMockCipher("org1", [], true, {
delete: true,
} as CipherPermissionsApi) as CipherView;
const organization = createMockOrganization();
mockOrganizationService.organizations$.mockReturnValue(of([organization] as Organization[]));
mockConfigService.getFeatureFlag$.mockReturnValue(of(true));
cipherAuthorizationService.canDeleteCipher$(cipher, [], false).subscribe((result) => {
expect(result).toBe(true);
expect(mockCollectionService.decryptedCollectionViews$).not.toHaveBeenCalled();
done();
});
});
it("should return false if feature flag enabled and cipher.permissions.delete is false", (done) => {
const cipher = createMockCipher("org1", []) as CipherView;
const organization = createMockOrganization();
mockOrganizationService.organizations$.mockReturnValue(of([organization] as Organization[]));
mockConfigService.getFeatureFlag$.mockReturnValue(of(true));
cipherAuthorizationService.canDeleteCipher$(cipher, [], false).subscribe((result) => {
expect(result).toBe(false);
expect(mockCollectionService.decryptedCollectionViews$).not.toHaveBeenCalled();
done();
});
});
});
describe("canCloneCipher$", () => {

View File

@@ -1,12 +1,13 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { map, Observable, of, shareReplay, switchMap } from "rxjs";
import { combineLatest, map, Observable, of, shareReplay, switchMap } from "rxjs";
import { CollectionService } from "@bitwarden/admin-console/common";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { CollectionId } from "@bitwarden/common/types/guid";
import { getUserId } from "../../auth/services/account.service";
import { FeatureFlag } from "../../enums/feature-flag.enum";
import { Cipher } from "../models/domain/cipher";
import { CipherView } from "../models/view/cipher.view";
@@ -28,12 +29,25 @@ export abstract class CipherAuthorizationService {
*
* @returns {Observable<boolean>} - An observable that emits a boolean value indicating if the user can delete the cipher.
*/
canDeleteCipher$: (
abstract canDeleteCipher$: (
cipher: CipherLike,
allowedCollections?: CollectionId[],
isAdminConsoleAction?: boolean,
) => Observable<boolean>;
/**
* Determines if the user can restore the specified cipher.
*
* @param {CipherLike} cipher - The cipher object to evaluate for restore permissions.
* @param {boolean} isAdminConsoleAction - Optional. A flag indicating if the action is being performed from the admin console.
*
* @returns {Observable<boolean>} - An observable that emits a boolean value indicating if the user can restore the cipher.
*/
abstract canRestoreCipher$: (
cipher: CipherLike,
isAdminConsoleAction?: boolean,
) => Observable<boolean>;
/**
* Determines if the user can clone the specified cipher.
*
@@ -42,7 +56,10 @@ export abstract class CipherAuthorizationService {
*
* @returns {Observable<boolean>} - An observable that emits a boolean value indicating if the user can clone the cipher.
*/
canCloneCipher$: (cipher: CipherLike, isAdminConsoleAction?: boolean) => Observable<boolean>;
abstract canCloneCipher$: (
cipher: CipherLike,
isAdminConsoleAction?: boolean,
) => Observable<boolean>;
}
/**
@@ -53,13 +70,16 @@ export class DefaultCipherAuthorizationService implements CipherAuthorizationSer
private collectionService: CollectionService,
private organizationService: OrganizationService,
private accountService: AccountService,
private configService: ConfigService,
) {}
private organization$ = (cipher: CipherLike) =>
this.accountService.activeAccount$.pipe(
switchMap((account) => this.organizationService.organizations$(account?.id)),
getUserId,
switchMap((userId) => this.organizationService.organizations$(userId)),
map((orgs) => orgs.find((org) => org.id === cipher.organizationId)),
);
/**
*
* {@link CipherAuthorizationService.canDeleteCipher$}
@@ -69,12 +89,11 @@ export class DefaultCipherAuthorizationService implements CipherAuthorizationSer
allowedCollections?: CollectionId[],
isAdminConsoleAction?: boolean,
): Observable<boolean> {
if (cipher.organizationId == null) {
return of(true);
}
return this.organization$(cipher).pipe(
switchMap((organization) => {
return combineLatest([
this.organization$(cipher),
this.configService.getFeatureFlag$(FeatureFlag.LimitItemDeletion),
]).pipe(
switchMap(([organization, featureFlagEnabled]) => {
if (isAdminConsoleAction) {
// If the user is an admin, they can delete an unassigned cipher
if (!cipher.collectionIds || cipher.collectionIds.length === 0) {
@@ -86,6 +105,14 @@ export class DefaultCipherAuthorizationService implements CipherAuthorizationSer
}
}
if (featureFlagEnabled) {
return of(cipher.permissions.delete);
}
if (cipher.organizationId == null) {
return of(true);
}
return this.collectionService
.decryptedCollectionViews$(cipher.collectionIds as CollectionId[])
.pipe(
@@ -93,7 +120,7 @@ export class DefaultCipherAuthorizationService implements CipherAuthorizationSer
const shouldFilter = allowedCollections?.some(Boolean);
const collections = shouldFilter
? allCollections.filter((c) => allowedCollections.includes(c.id as CollectionId))
? allCollections.filter((c) => allowedCollections?.includes(c.id as CollectionId))
: allCollections;
return collections.some((collection) => collection.manage);
@@ -103,6 +130,29 @@ export class DefaultCipherAuthorizationService implements CipherAuthorizationSer
);
}
/**
*
* {@link CipherAuthorizationService.canRestoreCipher$}
*/
canRestoreCipher$(cipher: CipherLike, isAdminConsoleAction?: boolean): Observable<boolean> {
return this.organization$(cipher).pipe(
map((organization) => {
if (isAdminConsoleAction) {
// If the user is an admin, they can restore an unassigned cipher
if (!cipher.collectionIds || cipher.collectionIds.length === 0) {
return organization?.canEditUnassignedCiphers === true;
}
if (organization?.canEditAllCiphers) {
return true;
}
}
return cipher.permissions.restore;
}),
);
}
/**
* {@link CipherAuthorizationService.canCloneCipher$}
*/
@@ -116,6 +166,7 @@ export class DefaultCipherAuthorizationService implements CipherAuthorizationSer
// Admins and custom users can always clone when in the Admin Console
if (
isAdminConsoleAction &&
organization &&
(organization.isAdmin || organization.permissions?.editAnyCollection)
) {
return of(true);

View File

@@ -27,6 +27,7 @@ import { CipherFileUploadService } from "../abstractions/file-upload/cipher-file
import { FieldType } from "../enums";
import { CipherRepromptType } from "../enums/cipher-reprompt-type";
import { CipherType } from "../enums/cipher-type";
import { CipherPermissionsApi } from "../models/api/cipher-permissions.api";
import { CipherData } from "../models/data/cipher.data";
import { Cipher } from "../models/domain/cipher";
import { CipherCreateRequest } from "../models/request/cipher-create.request";
@@ -57,6 +58,7 @@ const cipherData: CipherData = {
notes: "EncryptedString",
creationDate: "2022-01-01T12:00:00.000Z",
deletedDate: null,
permissions: new CipherPermissionsApi(),
key: "EncKey",
reprompt: CipherRepromptType.None,
login: {
@@ -363,7 +365,8 @@ describe("Cipher Service", () => {
configService.getFeatureFlag.mockResolvedValue(true);
configService.checkServerMeetsVersionRequirement$.mockReturnValue(of(true));
searchService.indexedEntityId$ = of(null);
searchService.indexedEntityId$.mockReturnValue(of(null));
stateService.getUserId.mockResolvedValue(mockUserId);
const keys = {

View File

@@ -165,9 +165,9 @@ export class CipherService implements CipherServiceAbstraction {
}
if (this.searchService != null) {
if (value == null) {
await this.searchService.clearIndex();
await this.searchService.clearIndex(userId);
} else {
await this.searchService.indexCiphers(value);
await this.searchService.indexCiphers(userId, value);
}
}
}
@@ -480,9 +480,9 @@ export class CipherService implements CipherServiceAbstraction {
private async reindexCiphers(userId: UserId) {
const reindexRequired =
this.searchService != null &&
((await firstValueFrom(this.searchService.indexedEntityId$)) ?? userId) !== userId;
((await firstValueFrom(this.searchService.indexedEntityId$(userId))) ?? userId) !== userId;
if (reindexRequired) {
await this.searchService.indexCiphers(await this.getDecryptedCiphers(userId), userId);
await this.searchService.indexCiphers(userId, await this.getDecryptedCiphers(userId), userId);
}
}

View File

@@ -1,17 +1,39 @@
import { mock } from "jest-mock-extended";
import { of, take } from "rxjs";
import { LogService } from "../../platform/abstractions/log.service";
import { WebCryptoFunctionService } from "../../platform/services/web-crypto-function.service";
import { BitwardenClient, TotpResponse } from "@bitwarden/sdk-internal";
import { SdkService } from "../../platform/abstractions/sdk/sdk.service";
import { TotpService } from "./totp.service";
describe("TotpService", () => {
let totpService: TotpService;
let generateTotpMock: jest.Mock;
const logService = mock<LogService>();
const sdkService = mock<SdkService>();
beforeEach(() => {
totpService = new TotpService(new WebCryptoFunctionService(global), logService);
generateTotpMock = jest
.fn()
.mockReturnValueOnce({
code: "123456",
period: 30,
})
.mockReturnValueOnce({ code: "654321", period: 30 })
.mockReturnValueOnce({ code: "567892", period: 30 });
const mockBitwardenClient = {
vault: () => ({
totp: () => ({
generate_totp: generateTotpMock,
}),
}),
};
sdkService.client$ = of(mockBitwardenClient as unknown as BitwardenClient);
totpService = new TotpService(sdkService);
// TOTP is time-based, so we need to mock the current time
jest.useFakeTimers({
@@ -24,40 +46,50 @@ describe("TotpService", () => {
jest.useRealTimers();
});
it("should return null if key is null", async () => {
const result = await totpService.getCode(null);
expect(result).toBeNull();
});
describe("getCode$", () => {
it("should emit TOTP response when key is provided", (done) => {
totpService
.getCode$("WQIQ25BRKZYCJVYP")
.pipe(take(1))
.subscribe((result) => {
expect(result).toEqual({ code: "123456", period: 30 });
done();
});
it("should return a code if key is not null", async () => {
const result = await totpService.getCode("WQIQ25BRKZYCJVYP");
expect(result).toBe("194506");
});
jest.advanceTimersByTime(1000);
});
it("should handle otpauth keys", async () => {
const key = "otpauth://totp/test-account?secret=WQIQ25BRKZYCJVYP";
const result = await totpService.getCode(key);
expect(result).toBe("194506");
it("should emit TOTP response every second", () => {
const responses: TotpResponse[] = [];
const period = totpService.getTimeInterval(key);
expect(period).toBe(30);
});
totpService
.getCode$("WQIQ25BRKZYCJVYP")
.pipe(take(3))
.subscribe((result) => {
responses.push(result);
});
it("should handle otpauth different period", async () => {
const key = "otpauth://totp/test-account?secret=WQIQ25BRKZYCJVYP&period=60";
const result = await totpService.getCode(key);
expect(result).toBe("730364");
jest.advanceTimersByTime(2000);
const period = totpService.getTimeInterval(key);
expect(period).toBe(60);
});
expect(responses).toEqual([
{ code: "123456", period: 30 },
{ code: "654321", period: 30 },
{ code: "567892", period: 30 },
]);
});
it("should handle steam keys", async () => {
const key = "steam://HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ";
const result = await totpService.getCode(key);
expect(result).toBe("7W6CJ");
it("should stop emitting TOTP response after unsubscribing", () => {
const responses: TotpResponse[] = [];
const period = totpService.getTimeInterval(key);
expect(period).toBe(30);
const subscription = totpService.getCode$("WQIQ25BRKZYCJVYP").subscribe((result) => {
responses.push(result);
});
jest.advanceTimersByTime(1000);
subscription.unsubscribe();
jest.advanceTimersByTime(1000);
expect(responses).toHaveLength(2);
});
});
});

View File

@@ -1,170 +1,43 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { CryptoFunctionService } from "../../platform/abstractions/crypto-function.service";
import { LogService } from "../../platform/abstractions/log.service";
import { Utils } from "../../platform/misc/utils";
import { Observable, map, shareReplay, switchMap, timer } from "rxjs";
import { TotpResponse } from "@bitwarden/sdk-internal";
import { SdkService } from "../../platform/abstractions/sdk/sdk.service";
import { TotpService as TotpServiceAbstraction } from "../abstractions/totp.service";
const B32Chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
const SteamChars = "23456789BCDFGHJKMNPQRTVWXY";
/**
* Represents TOTP information including display formatting and timing
*/
export type TotpInfo = {
/** The TOTP code value */
totpCode: string;
/** The TOTP code value formatted for display, includes spaces */
totpCodeFormatted: string;
/** Progress bar percentage value */
totpDash: number;
/** Seconds remaining until the TOTP code changes */
totpSec: number;
/** Indicates when the code is close to expiring */
totpLow: boolean;
};
export class TotpService implements TotpServiceAbstraction {
constructor(
private cryptoFunctionService: CryptoFunctionService,
private logService: LogService,
) {}
constructor(private sdkService: SdkService) {}
async getCode(key: string): Promise<string> {
if (key == null) {
return null;
}
let period = 30;
let alg: "sha1" | "sha256" | "sha512" = "sha1";
let digits = 6;
let keyB32 = key;
const isOtpAuth = key.toLowerCase().indexOf("otpauth://") === 0;
const isSteamAuth = !isOtpAuth && key.toLowerCase().indexOf("steam://") === 0;
if (isOtpAuth) {
const params = Utils.getQueryParams(key);
if (params.has("digits") && params.get("digits") != null) {
try {
const digitParams = parseInt(params.get("digits").trim(), null);
if (digitParams > 10) {
digits = 10;
} else if (digitParams > 0) {
digits = digitParams;
}
} catch {
this.logService.error("Invalid digits param.");
}
}
if (params.has("period") && params.get("period") != null) {
try {
const periodParam = parseInt(params.get("period").trim(), null);
if (periodParam > 0) {
period = periodParam;
}
} catch {
this.logService.error("Invalid period param.");
}
}
if (params.has("secret") && params.get("secret") != null) {
keyB32 = params.get("secret");
}
if (params.has("algorithm") && params.get("algorithm") != null) {
const algParam = params.get("algorithm").toLowerCase();
if (algParam === "sha1" || algParam === "sha256" || algParam === "sha512") {
alg = algParam;
}
}
} else if (isSteamAuth) {
keyB32 = key.substr("steam://".length);
digits = 5;
}
const epoch = Math.round(new Date().getTime() / 1000.0);
const timeHex = this.leftPad(this.decToHex(Math.floor(epoch / period)), 16, "0");
const timeBytes = Utils.fromHexToArray(timeHex);
const keyBytes = this.b32ToBytes(keyB32);
if (!keyBytes.length || !timeBytes.length) {
return null;
}
const hash = await this.sign(keyBytes, timeBytes, alg);
if (hash.length === 0) {
return null;
}
const offset = hash[hash.length - 1] & 0xf;
const binary =
((hash[offset] & 0x7f) << 24) |
((hash[offset + 1] & 0xff) << 16) |
((hash[offset + 2] & 0xff) << 8) |
(hash[offset + 3] & 0xff);
let otp = "";
if (isSteamAuth) {
let fullCode = binary & 0x7fffffff;
for (let i = 0; i < digits; i++) {
otp += SteamChars[fullCode % SteamChars.length];
fullCode = Math.trunc(fullCode / SteamChars.length);
}
} else {
otp = (binary % Math.pow(10, digits)).toString();
otp = this.leftPad(otp, digits, "0");
}
return otp;
}
getTimeInterval(key: string): number {
let period = 30;
if (key != null && key.toLowerCase().indexOf("otpauth://") === 0) {
const params = Utils.getQueryParams(key);
if (params.has("period") && params.get("period") != null) {
try {
period = parseInt(params.get("period").trim(), null);
} catch {
this.logService.error("Invalid period param.");
}
}
}
return period;
}
// Helpers
private leftPad(s: string, l: number, p: string): string {
if (l + 1 >= s.length) {
s = Array(l + 1 - s.length).join(p) + s;
}
return s;
}
private decToHex(d: number): string {
return (d < 15.5 ? "0" : "") + Math.round(d).toString(16);
}
private b32ToHex(s: string): string {
s = s.toUpperCase();
let cleanedInput = "";
for (let i = 0; i < s.length; i++) {
if (B32Chars.indexOf(s[i]) < 0) {
continue;
}
cleanedInput += s[i];
}
s = cleanedInput;
let bits = "";
let hex = "";
for (let i = 0; i < s.length; i++) {
const byteIndex = B32Chars.indexOf(s.charAt(i));
if (byteIndex < 0) {
continue;
}
bits += this.leftPad(byteIndex.toString(2), 5, "0");
}
for (let i = 0; i + 4 <= bits.length; i += 4) {
const chunk = bits.substr(i, 4);
hex = hex + parseInt(chunk, 2).toString(16);
}
return hex;
}
private b32ToBytes(s: string): Uint8Array {
return Utils.fromHexToArray(this.b32ToHex(s));
}
private async sign(
keyBytes: Uint8Array,
timeBytes: Uint8Array,
alg: "sha1" | "sha256" | "sha512",
) {
const signature = await this.cryptoFunctionService.hmac(timeBytes, keyBytes, alg);
return new Uint8Array(signature);
getCode$(key: string): Observable<TotpResponse> {
return timer(0, 1000).pipe(
switchMap(() =>
this.sdkService.client$.pipe(
map((sdk) => {
return sdk.vault().totp().generate_totp(key);
}),
),
),
shareReplay({ refCount: true, bufferSize: 1 }),
);
}
}

View File

@@ -6,12 +6,8 @@
"@bitwarden/auth/common": ["../auth/src/common"],
// TODO: Remove once circular dependencies in admin-console, auth and key-management are resolved
"@bitwarden/common/*": ["../common/src/*"],
// TODO: Remove once billing stops depending on components
"@bitwarden/components": ["../components/src"],
"@bitwarden/key-management": ["../key-management/src"],
"@bitwarden/platform": ["../platform/src"],
// TODO: Remove once billing stops depending on components
"@bitwarden/ui-common": ["../ui/common/src"]
"@bitwarden/vault-export-core": ["../tools/export/vault-export/vault-export-core/src"]
}
},
"include": ["src", "spec", "./custom-matchers.d.ts", "../key-management/src/index.ts"],