1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-10 21:50:15 +00:00

Merge branch 'main' into auth/pm-18458/create-change-existing-password-component

This commit is contained in:
rr-bw
2025-04-24 22:24:37 -07:00
513 changed files with 8863 additions and 14140 deletions

View File

@@ -58,6 +58,7 @@ describe("ORGANIZATIONS state", () => {
familySponsorshipLastSyncDate: new Date(),
userIsManagedByOrganization: false,
useRiskInsights: false,
useAdminSponsoredFamilies: false,
},
};
const result = sut.deserializer(JSON.parse(JSON.stringify(expectedResult)));

View File

@@ -60,6 +60,7 @@ export class OrganizationData {
allowAdminAccessToAllCollectionItems: boolean;
userIsManagedByOrganization: boolean;
useRiskInsights: boolean;
useAdminSponsoredFamilies: boolean;
constructor(
response?: ProfileOrganizationResponse,
@@ -122,6 +123,7 @@ export class OrganizationData {
this.allowAdminAccessToAllCollectionItems = response.allowAdminAccessToAllCollectionItems;
this.userIsManagedByOrganization = response.userIsManagedByOrganization;
this.useRiskInsights = response.useRiskInsights;
this.useAdminSponsoredFamilies = response.useAdminSponsoredFamilies;
this.isMember = options.isMember;
this.isProviderUser = options.isProviderUser;

View File

@@ -90,6 +90,7 @@ export class Organization {
*/
userIsManagedByOrganization: boolean;
useRiskInsights: boolean;
useAdminSponsoredFamilies: boolean;
constructor(obj?: OrganizationData) {
if (obj == null) {
@@ -148,6 +149,7 @@ export class Organization {
this.allowAdminAccessToAllCollectionItems = obj.allowAdminAccessToAllCollectionItems;
this.userIsManagedByOrganization = obj.userIsManagedByOrganization;
this.useRiskInsights = obj.useRiskInsights;
this.useAdminSponsoredFamilies = obj.useAdminSponsoredFamilies;
}
get canAccess() {

View File

@@ -6,4 +6,5 @@ export class OrganizationSponsorshipCreateRequest {
sponsoredEmail: string;
planSponsorshipType: PlanSponsorshipType;
friendlyName: string;
notes?: string;
}

View File

@@ -55,6 +55,7 @@ export class ProfileOrganizationResponse extends BaseResponse {
allowAdminAccessToAllCollectionItems: boolean;
userIsManagedByOrganization: boolean;
useRiskInsights: boolean;
useAdminSponsoredFamilies: boolean;
constructor(response: any) {
super(response);
@@ -121,5 +122,6 @@ export class ProfileOrganizationResponse extends BaseResponse {
);
this.userIsManagedByOrganization = this.getResponseProperty("UserIsManagedByOrganization");
this.useRiskInsights = this.getResponseProperty("UseRiskInsights");
this.useAdminSponsoredFamilies = this.getResponseProperty("UseAdminSponsoredFamilies");
}
}

View File

@@ -15,6 +15,9 @@ export class DeviceResponse extends BaseResponse {
creationDate: string;
revisionDate: string;
isTrusted: boolean;
encryptedUserKey: string | null;
encryptedPublicKey: string | null;
devicePendingAuthRequest: DevicePendingAuthRequest | null;
constructor(response: any) {
@@ -27,6 +30,8 @@ export class DeviceResponse extends BaseResponse {
this.creationDate = this.getResponseProperty("CreationDate");
this.revisionDate = this.getResponseProperty("RevisionDate");
this.isTrusted = this.getResponseProperty("IsTrusted");
this.encryptedUserKey = this.getResponseProperty("EncryptedUserKey");
this.encryptedPublicKey = this.getResponseProperty("EncryptedPublicKey");
this.devicePendingAuthRequest = this.getResponseProperty("DevicePendingAuthRequest");
}
}

View File

@@ -1,5 +1,8 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Observable } from "rxjs";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { OrganizationResponse } from "../../admin-console/models/response/organization.response";
import { InitiationPath } from "../../models/request/reference-event.request";
@@ -59,4 +62,10 @@ export abstract class OrganizationBillingServiceAbstraction {
organizationId: string,
subscription: SubscriptionInformation,
) => Promise<void>;
/**
* Determines if breadcrumbing policies is enabled for the organizations meeting certain criteria.
* @param organization
*/
abstract isBreadcrumbingPoliciesEnabled$(organization: Organization): Observable<boolean>;
}

View File

@@ -1,4 +1,4 @@
import { PlanType } from "../../enums";
import { PlanSponsorshipType, PlanType } from "../../enums";
export class PreviewOrganizationInvoiceRequest {
organizationId?: string;
@@ -21,6 +21,7 @@ export class PreviewOrganizationInvoiceRequest {
class PasswordManager {
plan: PlanType;
sponsoredPlan?: PlanSponsorshipType;
seats: number;
additionalStorage: number;

View File

@@ -0,0 +1,149 @@
import { mock } from "jest-mock-extended";
import { firstValueFrom, of } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction as OrganizationApiService } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
import { ProductTierType } from "@bitwarden/common/billing/enums";
import { OrganizationBillingService } from "@bitwarden/common/billing/services/organization-billing.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SyncService } from "@bitwarden/common/platform/sync";
import { KeyService } from "@bitwarden/key-management";
describe("BillingAccountProfileStateService", () => {
let apiService: jest.Mocked<ApiService>;
let billingApiService: jest.Mocked<BillingApiServiceAbstraction>;
let keyService: jest.Mocked<KeyService>;
let encryptService: jest.Mocked<EncryptService>;
let i18nService: jest.Mocked<I18nService>;
let organizationApiService: jest.Mocked<OrganizationApiService>;
let syncService: jest.Mocked<SyncService>;
let configService: jest.Mocked<ConfigService>;
let sut: OrganizationBillingService;
beforeEach(() => {
apiService = mock<ApiService>();
billingApiService = mock<BillingApiServiceAbstraction>();
keyService = mock<KeyService>();
encryptService = mock<EncryptService>();
i18nService = mock<I18nService>();
organizationApiService = mock<OrganizationApiService>();
syncService = mock<SyncService>();
configService = mock<ConfigService>();
sut = new OrganizationBillingService(
apiService,
billingApiService,
keyService,
encryptService,
i18nService,
organizationApiService,
syncService,
configService,
);
});
afterEach(() => {
return jest.resetAllMocks();
});
describe("isBreadcrumbingPoliciesEnabled", () => {
it("returns false when feature flag is disabled", async () => {
configService.getFeatureFlag$.mockReturnValue(of(false));
const org = {
isProviderUser: false,
canEditSubscription: true,
productTierType: ProductTierType.Teams,
} as Organization;
const actual = await firstValueFrom(sut.isBreadcrumbingPoliciesEnabled$(org));
expect(actual).toBe(false);
expect(configService.getFeatureFlag$).toHaveBeenCalledWith(
FeatureFlag.PM12276_BreadcrumbEventLogs,
);
});
it("returns false when organization belongs to a provider", async () => {
configService.getFeatureFlag$.mockReturnValue(of(true));
const org = {
isProviderUser: true,
canEditSubscription: true,
productTierType: ProductTierType.Teams,
} as Organization;
const actual = await firstValueFrom(sut.isBreadcrumbingPoliciesEnabled$(org));
expect(actual).toBe(false);
});
it("returns false when cannot edit subscription", async () => {
configService.getFeatureFlag$.mockReturnValue(of(true));
const org = {
isProviderUser: false,
canEditSubscription: false,
productTierType: ProductTierType.Teams,
} as Organization;
const actual = await firstValueFrom(sut.isBreadcrumbingPoliciesEnabled$(org));
expect(actual).toBe(false);
});
it.each([
["Teams", ProductTierType.Teams],
["TeamsStarter", ProductTierType.TeamsStarter],
])("returns true when all conditions are met with %s tier", async (_, productTierType) => {
configService.getFeatureFlag$.mockReturnValue(of(true));
const org = {
isProviderUser: false,
canEditSubscription: true,
productTierType: productTierType,
} as Organization;
const actual = await firstValueFrom(sut.isBreadcrumbingPoliciesEnabled$(org));
expect(actual).toBe(true);
expect(configService.getFeatureFlag$).toHaveBeenCalledWith(
FeatureFlag.PM12276_BreadcrumbEventLogs,
);
});
it("returns false when product tier is not supported", async () => {
configService.getFeatureFlag$.mockReturnValue(of(true));
const org = {
isProviderUser: false,
canEditSubscription: true,
productTierType: ProductTierType.Enterprise,
} as Organization;
const actual = await firstValueFrom(sut.isBreadcrumbingPoliciesEnabled$(org));
expect(actual).toBe(false);
});
it("handles all conditions false correctly", async () => {
configService.getFeatureFlag$.mockReturnValue(of(false));
const org = {
isProviderUser: true,
canEditSubscription: false,
productTierType: ProductTierType.Free,
} as Organization;
const actual = await firstValueFrom(sut.isBreadcrumbingPoliciesEnabled$(org));
expect(actual).toBe(false);
});
it("verifies feature flag is only called once", async () => {
configService.getFeatureFlag$.mockReturnValue(of(false));
const org = {
isProviderUser: false,
canEditSubscription: true,
productTierType: ProductTierType.Teams,
} as Organization;
await firstValueFrom(sut.isBreadcrumbingPoliciesEnabled$(org));
expect(configService.getFeatureFlag$).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -1,5 +1,10 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Observable, of, switchMap } from "rxjs";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { KeyService } from "@bitwarden/key-management";
import { ApiService } from "../../abstractions/api.service";
@@ -20,7 +25,7 @@ import {
PlanInformation,
SubscriptionInformation,
} from "../abstractions";
import { PlanType } from "../enums";
import { PlanType, ProductTierType } from "../enums";
import { OrganizationNoPaymentMethodCreateRequest } from "../models/request/organization-no-payment-method-create-request";
import { PaymentSourceResponse } from "../models/response/payment-source.response";
@@ -40,6 +45,7 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs
private i18nService: I18nService,
private organizationApiService: OrganizationApiService,
private syncService: SyncService,
private configService: ConfigService,
) {}
async getPaymentSource(organizationId: string): Promise<PaymentSourceResponse> {
@@ -220,4 +226,29 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs
this.setPaymentInformation(request, subscription.payment);
await this.billingApiService.restartSubscription(organizationId, request);
}
isBreadcrumbingPoliciesEnabled$(organization: Organization): Observable<boolean> {
if (organization === null || organization === undefined) {
return of(false);
}
return this.configService.getFeatureFlag$(FeatureFlag.PM12276_BreadcrumbEventLogs).pipe(
switchMap((featureFlagEnabled) => {
if (!featureFlagEnabled) {
return of(false);
}
if (organization.isProviderUser || !organization.canEditSubscription) {
return of(false);
}
const supportedProducts = [ProductTierType.Teams, ProductTierType.TeamsStarter];
const isSupportedProduct = supportedProducts.some(
(product) => product === organization.productTierType,
);
return of(isSupportedProduct);
}),
);
}
}

View File

@@ -25,7 +25,6 @@ export enum FeatureFlag {
EnableNewCardCombinedExpiryAutofill = "enable-new-card-combined-expiry-autofill",
GenerateIdentityFillScriptRefactor = "generate-identity-fill-script-refactor",
IdpAutoSubmitLogin = "idp-auto-submit-login",
InlineMenuPositioningImprovements = "inline-menu-positioning-improvements",
NotificationRefresh = "notification-refresh",
UseTreeWalkerApiForPageDetailsCollection = "use-tree-walker-api-for-page-details-collection",
MacOsNativeCredentialSync = "macos-native-credential-sync",
@@ -35,6 +34,11 @@ export enum FeatureFlag {
PM15179_AddExistingOrgsFromProviderPortal = "pm-15179-add-existing-orgs-from-provider-portal",
PM12276_BreadcrumbEventLogs = "pm-12276-breadcrumbing-for-business-features",
PM18794_ProviderPaymentMethod = "pm-18794-provider-payment-method",
PM17772_AdminInitiatedSponsorships = "pm-17772-admin-initiated-sponsorships",
/* Data Insights and Reporting */
CriticalApps = "pm-14466-risk-insights-critical-application",
EnableRiskInsightsNotifications = "enable-risk-insights-notifications",
/* Key Management */
PrivateKeyRegeneration = "pm-12241-private-key-regeneration",
@@ -44,10 +48,7 @@ export enum FeatureFlag {
/* Tools */
ItemShare = "item-share",
CriticalApps = "pm-14466-risk-insights-critical-application",
EnableRiskInsightsNotifications = "enable-risk-insights-notifications",
DesktopSendUIRefresh = "desktop-send-ui-refresh",
ExportAttachments = "export-attachments",
/* Vault */
PM8851_BrowserOnboardingNudge = "pm-8851-browser-onboarding-nudge",
@@ -57,6 +58,8 @@ export enum FeatureFlag {
VaultBulkManagementAction = "vault-bulk-management-action",
SecurityTasks = "security-tasks",
CipherKeyEncryption = "cipher-key-encryption",
PM18520_UpdateDesktopCipherForm = "pm-18520-desktop-cipher-forms",
EndUserNotifications = "pm-10609-end-user-notifications",
/* Platform */
IpcChannelFramework = "ipc-channel-framework",
@@ -89,17 +92,17 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.EnableNewCardCombinedExpiryAutofill]: FALSE,
[FeatureFlag.GenerateIdentityFillScriptRefactor]: FALSE,
[FeatureFlag.IdpAutoSubmitLogin]: FALSE,
[FeatureFlag.InlineMenuPositioningImprovements]: FALSE,
[FeatureFlag.NotificationRefresh]: FALSE,
[FeatureFlag.UseTreeWalkerApiForPageDetailsCollection]: FALSE,
[FeatureFlag.MacOsNativeCredentialSync]: FALSE,
/* Tools */
[FeatureFlag.ItemShare]: FALSE,
/* Data Insights and Reporting */
[FeatureFlag.CriticalApps]: FALSE,
[FeatureFlag.EnableRiskInsightsNotifications]: FALSE,
/* Tools */
[FeatureFlag.ItemShare]: FALSE,
[FeatureFlag.DesktopSendUIRefresh]: FALSE,
[FeatureFlag.ExportAttachments]: FALSE,
/* Vault */
[FeatureFlag.PM8851_BrowserOnboardingNudge]: FALSE,
@@ -109,6 +112,8 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.VaultBulkManagementAction]: FALSE,
[FeatureFlag.SecurityTasks]: FALSE,
[FeatureFlag.CipherKeyEncryption]: FALSE,
[FeatureFlag.PM18520_UpdateDesktopCipherForm]: FALSE,
[FeatureFlag.EndUserNotifications]: FALSE,
/* Auth */
[FeatureFlag.PM9112_DeviceApprovalPersistence]: FALSE,
@@ -119,6 +124,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.PM15179_AddExistingOrgsFromProviderPortal]: FALSE,
[FeatureFlag.PM12276_BreadcrumbEventLogs]: FALSE,
[FeatureFlag.PM18794_ProviderPaymentMethod]: FALSE,
[FeatureFlag.PM17772_AdminInitiatedSponsorships]: FALSE,
/* Key Management */
[FeatureFlag.PrivateKeyRegeneration]: FALSE,

View File

@@ -7,8 +7,50 @@ import { EncString } from "../../../platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
export abstract class EncryptService {
/**
* Encrypts a string or Uint8Array to an EncString
* @param plainValue - The value to encrypt
* @param key - The key to encrypt the value with
*/
abstract encrypt(plainValue: string | Uint8Array, key: SymmetricCryptoKey): Promise<EncString>;
/**
* Encrypts a value to a Uint8Array
* @param plainValue - The value to encrypt
* @param key - The key to encrypt the value with
*/
abstract encryptToBytes(plainValue: Uint8Array, key: SymmetricCryptoKey): Promise<EncArrayBuffer>;
/**
* Wraps a decapsulation key (Private key) with a symmetric key
* @see {@link https://en.wikipedia.org/wiki/Key_wrap}
* @param decapsulationKeyPcks8 - The private key in PKCS8 format
* @param wrappingKey - The symmetric key to wrap the private key with
*/
abstract wrapDecapsulationKey(
decapsulationKeyPcks8: Uint8Array,
wrappingKey: SymmetricCryptoKey,
): Promise<EncString>;
/**
* Wraps an encapsulation key (Public key) with a symmetric key
* @see {@link https://en.wikipedia.org/wiki/Key_wrap}
* @param encapsulationKeySpki - The public key in SPKI format
* @param wrappingKey - The symmetric key to wrap the public key with
*/
abstract wrapEncapsulationKey(
encapsulationKeySpki: Uint8Array,
wrappingKey: SymmetricCryptoKey,
): Promise<EncString>;
/**
* Wraps a symmetric key with another symmetric key
* @see {@link https://en.wikipedia.org/wiki/Key_wrap}
* @param keyToBeWrapped - The symmetric key to wrap
* @param wrappingKey - The symmetric key to wrap the encapsulated key with
*/
abstract wrapSymmetricKey(
keyToBeWrapped: SymmetricCryptoKey,
wrappingKey: SymmetricCryptoKey,
): Promise<EncString>;
/**
* Decrypts an EncString to a string
* @param encString - The EncString to decrypt
@@ -39,6 +81,7 @@ export abstract class EncryptService {
/**
* Encapsulates a symmetric key with an asymmetric public key
* Note: This does not establish sender authenticity
* @see {@link https://en.wikipedia.org/wiki/Key_encapsulation_mechanism}
* @param sharedKey - The symmetric key that is to be shared
* @param encapsulationKey - The encapsulation key (public key) of the receiver that the key is shared with
*/
@@ -49,6 +92,7 @@ export abstract class EncryptService {
/**
* Decapsulates a shared symmetric key with an asymmetric private key
* Note: This does not establish sender authenticity
* @see {@link https://en.wikipedia.org/wiki/Key_encapsulation_mechanism}
* @param encryptedSharedKey - The encrypted shared symmetric key
* @param decapsulationKey - The key to decapsulate with (private key)
*/
@@ -57,13 +101,13 @@ export abstract class EncryptService {
decapsulationKey: Uint8Array,
): Promise<SymmetricCryptoKey>;
/**
* @deprecated Use encapsulateKeyUnsigned instead
* @deprecated Use @see {@link encapsulateKeyUnsigned} instead
* @param data - The data to encrypt
* @param publicKey - The public key to encrypt with
*/
abstract rsaEncrypt(data: Uint8Array, publicKey: Uint8Array): Promise<EncString>;
/**
* @deprecated Use decapsulateKeyUnsigned instead
* @deprecated Use @see {@link decapsulateKeyUnsigned} instead
* @param data - The ciphertext to decrypt
* @param privateKey - The privateKey to decrypt with
*/

View File

@@ -47,7 +47,7 @@ export class EncryptServiceImplementation implements EncryptService {
}
if (this.blockType0) {
if (key.encType === EncryptionType.AesCbc256_B64 || key.key.byteLength < 64) {
if (key.inner().type === EncryptionType.AesCbc256_B64 || key.key.byteLength < 64) {
throw new Error("Type 0 encryption is not supported.");
}
}
@@ -56,22 +56,85 @@ export class EncryptServiceImplementation implements EncryptService {
return Promise.resolve(null);
}
let plainBuf: Uint8Array;
if (typeof plainValue === "string") {
plainBuf = Utils.fromUtf8ToArray(plainValue);
return this.encryptUint8Array(Utils.fromUtf8ToArray(plainValue), key);
} else {
plainBuf = plainValue;
return this.encryptUint8Array(plainValue, key);
}
}
async wrapDecapsulationKey(
decapsulationKeyPkcs8: Uint8Array,
wrappingKey: SymmetricCryptoKey,
): Promise<EncString> {
if (decapsulationKeyPkcs8 == null) {
throw new Error("No decapsulation key provided for wrapping.");
}
if (wrappingKey == null) {
throw new Error("No wrappingKey provided for wrapping.");
}
return await this.encryptUint8Array(decapsulationKeyPkcs8, wrappingKey);
}
async wrapEncapsulationKey(
encapsulationKeySpki: Uint8Array,
wrappingKey: SymmetricCryptoKey,
): Promise<EncString> {
if (encapsulationKeySpki == null) {
throw new Error("No encapsulation key provided for wrapping.");
}
if (wrappingKey == null) {
throw new Error("No wrappingKey provided for wrapping.");
}
return await this.encryptUint8Array(encapsulationKeySpki, wrappingKey);
}
async wrapSymmetricKey(
keyToBeWrapped: SymmetricCryptoKey,
wrappingKey: SymmetricCryptoKey,
): Promise<EncString> {
if (keyToBeWrapped == null) {
throw new Error("No keyToBeWrapped provided for wrapping.");
}
if (wrappingKey == null) {
throw new Error("No wrappingKey provided for wrapping.");
}
return await this.encryptUint8Array(keyToBeWrapped.key, wrappingKey);
}
private async encryptUint8Array(
plainValue: Uint8Array,
key: SymmetricCryptoKey,
): Promise<EncString> {
if (key == null) {
throw new Error("No encryption key provided.");
}
if (this.blockType0) {
if (key.inner().type === EncryptionType.AesCbc256_B64 || key.key.byteLength < 64) {
throw new Error("Type 0 encryption is not supported.");
}
}
if (plainValue == null) {
return Promise.resolve(null);
}
const innerKey = key.inner();
if (innerKey.type === EncryptionType.AesCbc256_HmacSha256_B64) {
const encObj = await this.aesEncrypt(plainBuf, innerKey);
const encObj = await this.aesEncrypt(plainValue, innerKey);
const iv = Utils.fromBufferToB64(encObj.iv);
const data = Utils.fromBufferToB64(encObj.data);
const mac = Utils.fromBufferToB64(encObj.mac);
return new EncString(innerKey.type, data, iv, mac);
} else if (innerKey.type === EncryptionType.AesCbc256_B64) {
const encObj = await this.aesEncryptLegacy(plainBuf, innerKey);
const encObj = await this.aesEncryptLegacy(plainValue, innerKey);
const iv = Utils.fromBufferToB64(encObj.iv);
const data = Utils.fromBufferToB64(encObj.data);
return new EncString(innerKey.type, data, iv);
@@ -84,7 +147,7 @@ export class EncryptServiceImplementation implements EncryptService {
}
if (this.blockType0) {
if (key.encType === EncryptionType.AesCbc256_B64 || key.key.byteLength < 64) {
if (key.inner().type === EncryptionType.AesCbc256_B64 || key.key.byteLength < 64) {
throw new Error("Type 0 encryption is not supported.");
}
}
@@ -124,7 +187,7 @@ export class EncryptServiceImplementation implements EncryptService {
if (encString.encryptionType !== innerKey.type) {
this.logDecryptError(
"Key encryption type does not match payload encryption type",
key.encType,
innerKey.type,
encString.encryptionType,
decryptContext,
);
@@ -148,7 +211,7 @@ export class EncryptServiceImplementation implements EncryptService {
if (!macsEqual) {
this.logMacFailed(
"decryptToUtf8 MAC comparison failed. Key or payload has changed.",
key.encType,
innerKey.type,
encString.encryptionType,
decryptContext,
);
@@ -191,7 +254,7 @@ export class EncryptServiceImplementation implements EncryptService {
if (encThing.encryptionType !== inner.type) {
this.logDecryptError(
"Encryption key type mismatch",
key.encType,
inner.type,
encThing.encryptionType,
decryptContext,
);
@@ -200,19 +263,23 @@ export class EncryptServiceImplementation implements EncryptService {
if (inner.type === EncryptionType.AesCbc256_HmacSha256_B64) {
if (encThing.macBytes == null) {
this.logDecryptError("Mac missing", key.encType, encThing.encryptionType, decryptContext);
this.logDecryptError("Mac missing", inner.type, encThing.encryptionType, decryptContext);
return null;
}
const macData = new Uint8Array(encThing.ivBytes.byteLength + encThing.dataBytes.byteLength);
macData.set(new Uint8Array(encThing.ivBytes), 0);
macData.set(new Uint8Array(encThing.dataBytes), encThing.ivBytes.byteLength);
const computedMac = await this.cryptoFunctionService.hmac(macData, key.macKey, "sha256");
const computedMac = await this.cryptoFunctionService.hmac(
macData,
inner.authenticationKey,
"sha256",
);
const macsMatch = await this.cryptoFunctionService.compare(encThing.macBytes, computedMac);
if (!macsMatch) {
this.logMacFailed(
"MAC comparison failed. Key or payload has changed.",
key.encType,
inner.type,
encThing.encryptionType,
decryptContext,
);
@@ -222,14 +289,14 @@ export class EncryptServiceImplementation implements EncryptService {
return await this.cryptoFunctionService.aesDecrypt(
encThing.dataBytes,
encThing.ivBytes,
key.encKey,
inner.encryptionKey,
"cbc",
);
} else if (inner.type === EncryptionType.AesCbc256_B64) {
return await this.cryptoFunctionService.aesDecrypt(
encThing.dataBytes,
encThing.ivBytes,
key.encKey,
inner.encryptionKey,
"cbc",
);
}

View File

@@ -6,7 +6,10 @@ import { EncryptionType } from "@bitwarden/common/platform/enums";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { EncArrayBuffer } from "@bitwarden/common/platform/models/domain/enc-array-buffer";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import {
Aes256CbcHmacKey,
SymmetricCryptoKey,
} from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { CsprngArray } from "@bitwarden/common/types/csprng";
import { makeStaticByteArray } from "../../../../spec";
@@ -28,6 +31,127 @@ describe("EncryptService", () => {
encryptService = new EncryptServiceImplementation(cryptoFunctionService, logService, true);
});
describe("wrapSymmetricKey", () => {
it("roundtrip encrypts and decrypts a symmetric key", async () => {
cryptoFunctionService.aesEncrypt.mockResolvedValue(makeStaticByteArray(64, 0));
cryptoFunctionService.randomBytes.mockResolvedValue(makeStaticByteArray(16) as CsprngArray);
cryptoFunctionService.hmac.mockResolvedValue(makeStaticByteArray(32));
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
const wrappingKey = new SymmetricCryptoKey(makeStaticByteArray(64));
const encString = await encryptService.wrapSymmetricKey(key, wrappingKey);
expect(encString.encryptionType).toEqual(EncryptionType.AesCbc256_HmacSha256_B64);
expect(encString.data).toEqual(Utils.fromBufferToB64(makeStaticByteArray(64, 0)));
});
it("fails if key toBeWrapped is null", async () => {
const wrappingKey = new SymmetricCryptoKey(makeStaticByteArray(64));
await expect(encryptService.wrapSymmetricKey(null, wrappingKey)).rejects.toThrow(
"No keyToBeWrapped provided for wrapping.",
);
});
it("fails if wrapping key is null", async () => {
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
await expect(encryptService.wrapSymmetricKey(key, null)).rejects.toThrow(
"No wrappingKey provided for wrapping.",
);
});
it("fails if type 0 key is provided with flag turned on", async () => {
(encryptService as any).blockType0 = true;
const mock32Key = mock<SymmetricCryptoKey>();
mock32Key.key = makeStaticByteArray(32);
mock32Key.inner.mockReturnValue({
type: 0,
encryptionKey: mock32Key.key,
});
await expect(encryptService.wrapSymmetricKey(mock32Key, mock32Key)).rejects.toThrow(
"Type 0 encryption is not supported.",
);
});
});
describe("wrapDecapsulationKey", () => {
it("roundtrip encrypts and decrypts a decapsulation key", async () => {
cryptoFunctionService.aesEncrypt.mockResolvedValue(makeStaticByteArray(64, 0));
cryptoFunctionService.randomBytes.mockResolvedValue(makeStaticByteArray(16) as CsprngArray);
cryptoFunctionService.hmac.mockResolvedValue(makeStaticByteArray(32));
const wrappingKey = new SymmetricCryptoKey(makeStaticByteArray(64));
const encString = await encryptService.wrapDecapsulationKey(
makeStaticByteArray(64),
wrappingKey,
);
expect(encString.encryptionType).toEqual(EncryptionType.AesCbc256_HmacSha256_B64);
expect(encString.data).toEqual(Utils.fromBufferToB64(makeStaticByteArray(64, 0)));
});
it("fails if decapsulation key is null", async () => {
const wrappingKey = new SymmetricCryptoKey(makeStaticByteArray(64));
await expect(encryptService.wrapDecapsulationKey(null, wrappingKey)).rejects.toThrow(
"No decapsulation key provided for wrapping.",
);
});
it("fails if wrapping key is null", async () => {
const decapsulationKey = makeStaticByteArray(64);
await expect(encryptService.wrapDecapsulationKey(decapsulationKey, null)).rejects.toThrow(
"No wrappingKey provided for wrapping.",
);
});
it("throws if type 0 key is provided with flag turned on", async () => {
(encryptService as any).blockType0 = true;
const mock32Key = mock<SymmetricCryptoKey>();
mock32Key.key = makeStaticByteArray(32);
mock32Key.inner.mockReturnValue({
type: 0,
encryptionKey: mock32Key.key,
});
await expect(
encryptService.wrapDecapsulationKey(new Uint8Array(200), mock32Key),
).rejects.toThrow("Type 0 encryption is not supported.");
});
});
describe("wrapEncapsulationKey", () => {
it("roundtrip encrypts and decrypts an encapsulationKey key", async () => {
cryptoFunctionService.aesEncrypt.mockResolvedValue(makeStaticByteArray(64, 0));
cryptoFunctionService.randomBytes.mockResolvedValue(makeStaticByteArray(16) as CsprngArray);
cryptoFunctionService.hmac.mockResolvedValue(makeStaticByteArray(32));
const wrappingKey = new SymmetricCryptoKey(makeStaticByteArray(64));
const encString = await encryptService.wrapEncapsulationKey(
makeStaticByteArray(64),
wrappingKey,
);
expect(encString.encryptionType).toEqual(EncryptionType.AesCbc256_HmacSha256_B64);
expect(encString.data).toEqual(Utils.fromBufferToB64(makeStaticByteArray(64, 0)));
});
it("fails if encapsulation key is null", async () => {
const wrappingKey = new SymmetricCryptoKey(makeStaticByteArray(64));
await expect(encryptService.wrapEncapsulationKey(null, wrappingKey)).rejects.toThrow(
"No encapsulation key provided for wrapping.",
);
});
it("fails if wrapping key is null", async () => {
const encapsulationKey = makeStaticByteArray(64);
await expect(encryptService.wrapEncapsulationKey(encapsulationKey, null)).rejects.toThrow(
"No wrappingKey provided for wrapping.",
);
});
it("throws if type 0 key is provided with flag turned on", async () => {
(encryptService as any).blockType0 = true;
const mock32Key = mock<SymmetricCryptoKey>();
mock32Key.key = makeStaticByteArray(32);
mock32Key.inner.mockReturnValue({
type: 0,
encryptionKey: mock32Key.key,
});
await expect(
encryptService.wrapEncapsulationKey(new Uint8Array(200), mock32Key),
).rejects.toThrow("Type 0 encryption is not supported.");
});
});
describe("onServerConfigChange", () => {
const newConfig = mock<ServerConfig>();
@@ -64,6 +188,10 @@ describe("EncryptService", () => {
const key = new SymmetricCryptoKey(makeStaticByteArray(32));
const mock32Key = mock<SymmetricCryptoKey>();
mock32Key.key = makeStaticByteArray(32);
mock32Key.inner.mockReturnValue({
type: 0,
encryptionKey: mock32Key.key,
});
await expect(encryptService.encrypt(null!, key)).rejects.toThrow(
"Type 0 encryption is not supported.",
@@ -146,6 +274,10 @@ describe("EncryptService", () => {
const key = new SymmetricCryptoKey(makeStaticByteArray(32));
const mock32Key = mock<SymmetricCryptoKey>();
mock32Key.key = makeStaticByteArray(32);
mock32Key.inner.mockReturnValue({
type: 0,
encryptionKey: mock32Key.key,
});
await expect(encryptService.encryptToBytes(plainValue, key)).rejects.toThrow(
"Type 0 encryption is not supported.",
@@ -228,7 +360,7 @@ describe("EncryptService", () => {
expect(cryptoFunctionService.aesDecrypt).toBeCalledWith(
expect.toEqualBuffer(encBuffer.dataBytes),
expect.toEqualBuffer(encBuffer.ivBytes),
expect.toEqualBuffer(key.encKey),
expect.toEqualBuffer(key.inner().encryptionKey),
"cbc",
);
@@ -249,7 +381,7 @@ describe("EncryptService", () => {
expect(cryptoFunctionService.aesDecrypt).toBeCalledWith(
expect.toEqualBuffer(encBuffer.dataBytes),
expect.toEqualBuffer(encBuffer.ivBytes),
expect.toEqualBuffer(key.encKey),
expect.toEqualBuffer(key.inner().encryptionKey),
"cbc",
);
@@ -267,7 +399,7 @@ describe("EncryptService", () => {
expect(cryptoFunctionService.hmac).toBeCalledWith(
expect.toEqualBuffer(expectedMacData),
key.macKey,
(key.inner() as Aes256CbcHmacKey).authenticationKey,
"sha256",
);
@@ -450,6 +582,12 @@ describe("EncryptService", () => {
expect(actual).toEqual(encString);
expect(actual.dataBytes).toEqualBuffer(encryptedData);
});
it("throws if no data was provided", () => {
return expect(encryptService.rsaEncrypt(null, new Uint8Array(32))).rejects.toThrow(
"No data provided for encryption",
);
});
});
describe("decapsulateKeyUnsigned", () => {

View File

@@ -1,6 +1,7 @@
import * as argon2 from "argon2-browser";
import * as forge from "node-forge";
import { EncryptionType } from "../../../platform/enums";
import { Utils } from "../../../platform/misc/utils";
import {
CbcDecryptParameters,
@@ -247,37 +248,26 @@ export class WebCryptoFunctionService implements CryptoFunctionService {
mac: string | null,
key: SymmetricCryptoKey,
): CbcDecryptParameters<string> {
const p = {} as CbcDecryptParameters<string>;
if (key.meta != null) {
p.encKey = key.meta.encKeyByteString;
p.macKey = key.meta.macKeyByteString;
const innerKey = key.inner();
if (innerKey.type === EncryptionType.AesCbc256_B64) {
return {
iv: forge.util.decode64(iv),
data: forge.util.decode64(data),
encKey: forge.util.createBuffer(innerKey.encryptionKey).getBytes(),
} as CbcDecryptParameters<string>;
} else if (innerKey.type === EncryptionType.AesCbc256_HmacSha256_B64) {
const macData = forge.util.decode64(iv) + forge.util.decode64(data);
return {
iv: forge.util.decode64(iv),
data: forge.util.decode64(data),
encKey: forge.util.createBuffer(innerKey.encryptionKey).getBytes(),
macKey: forge.util.createBuffer(innerKey.authenticationKey).getBytes(),
mac: forge.util.decode64(mac!),
macData,
} as CbcDecryptParameters<string>;
} else {
throw new Error("Unsupported encryption type.");
}
if (p.encKey == null) {
p.encKey = forge.util.decode64(key.encKeyB64);
}
p.data = forge.util.decode64(data);
p.iv = forge.util.decode64(iv);
p.macData = p.iv + p.data;
if (p.macKey == null && key.macKeyB64 != null) {
p.macKey = forge.util.decode64(key.macKeyB64);
}
if (mac != null) {
p.mac = forge.util.decode64(mac);
}
// cache byte string keys for later
if (key.meta == null) {
key.meta = {};
}
if (key.meta.encKeyByteString == null) {
key.meta.encKeyByteString = p.encKey;
}
if (p.macKey != null && key.meta.macKeyByteString == null) {
key.meta.macKeyByteString = p.macKey;
}
return p;
}
aesDecryptFast({

View File

@@ -164,10 +164,10 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
this.encryptService.encapsulateKeyUnsigned(userKey, devicePublicKey),
// Encrypt devicePublicKey with user key
this.encryptService.encrypt(devicePublicKey, userKey),
this.encryptService.wrapEncapsulationKey(devicePublicKey, userKey),
// Encrypt devicePrivateKey with deviceKey
this.encryptService.encrypt(devicePrivateKey, deviceKey),
this.encryptService.wrapDecapsulationKey(devicePrivateKey, deviceKey),
]);
// Send encrypted keys to server
@@ -209,9 +209,8 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
devices.data
.filter((device) => device.isTrusted)
.map(async (device) => {
const deviceWithKeys = await this.devicesApiService.getDeviceKeys(device.identifier);
const publicKey = await this.encryptService.decryptToBytes(
deviceWithKeys.encryptedPublicKey,
new EncString(device.encryptedPublicKey),
oldUserKey,
);
@@ -291,7 +290,7 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
);
// Re-encrypt the device public key with the new user key
const encryptedDevicePublicKey = await this.encryptService.encrypt(
const encryptedDevicePublicKey = await this.encryptService.wrapEncapsulationKey(
decryptedDevicePublicKey,
newUserKey,
);

View File

@@ -346,8 +346,6 @@ describe("deviceTrustService", () => {
const deviceRsaKeyLength = 2048;
let mockDeviceRsaKeyPair: [Uint8Array, Uint8Array];
let mockDevicePrivateKey: Uint8Array;
let mockDevicePublicKey: Uint8Array;
let mockDevicePublicKeyEncryptedUserKey: EncString;
let mockUserKeyEncryptedDevicePublicKey: EncString;
let mockDeviceKeyEncryptedDevicePrivateKey: EncString;
@@ -366,7 +364,8 @@ describe("deviceTrustService", () => {
let rsaGenerateKeyPairSpy: jest.SpyInstance;
let cryptoSvcGetUserKeySpy: jest.SpyInstance;
let cryptoSvcRsaEncryptSpy: jest.SpyInstance;
let encryptServiceEncryptSpy: jest.SpyInstance;
let encryptServiceWrapDecapsulationKeySpy: jest.SpyInstance;
let encryptServiceWrapEncapsulationKeySpy: jest.SpyInstance;
let appIdServiceGetAppIdSpy: jest.SpyInstance;
let devicesApiServiceUpdateTrustedDeviceKeysSpy: jest.SpyInstance;
@@ -384,9 +383,6 @@ describe("deviceTrustService", () => {
new Uint8Array(deviceRsaKeyLength),
];
mockDevicePublicKey = mockDeviceRsaKeyPair[0];
mockDevicePrivateKey = mockDeviceRsaKeyPair[1];
mockDevicePublicKeyEncryptedUserKey = new EncString(
EncryptionType.Rsa2048_OaepSha1_B64,
"mockDevicePublicKeyEncryptedUserKey",
@@ -419,13 +415,17 @@ describe("deviceTrustService", () => {
.spyOn(encryptService, "encapsulateKeyUnsigned")
.mockResolvedValue(mockDevicePublicKeyEncryptedUserKey);
encryptServiceEncryptSpy = jest
.spyOn(encryptService, "encrypt")
encryptServiceWrapEncapsulationKeySpy = jest
.spyOn(encryptService, "wrapEncapsulationKey")
.mockImplementation((plainValue, key) => {
if (plainValue === mockDevicePublicKey && key === mockUserKey) {
if (plainValue instanceof Uint8Array && key instanceof SymmetricCryptoKey) {
return Promise.resolve(mockUserKeyEncryptedDevicePublicKey);
}
if (plainValue === mockDevicePrivateKey && key === mockDeviceKey) {
});
encryptServiceWrapDecapsulationKeySpy = jest
.spyOn(encryptService, "wrapDecapsulationKey")
.mockImplementation((plainValue, key) => {
if (plainValue instanceof Uint8Array && key instanceof SymmetricCryptoKey) {
return Promise.resolve(mockDeviceKeyEncryptedDevicePrivateKey);
}
});
@@ -452,7 +452,8 @@ describe("deviceTrustService", () => {
const userKey = cryptoSvcRsaEncryptSpy.mock.calls[0][0];
expect(userKey.key.byteLength).toBe(64);
expect(encryptServiceEncryptSpy).toHaveBeenCalledTimes(2);
expect(encryptServiceWrapDecapsulationKeySpy).toHaveBeenCalledTimes(1);
expect(encryptServiceWrapEncapsulationKeySpy).toHaveBeenCalledTimes(1);
expect(appIdServiceGetAppIdSpy).toHaveBeenCalledTimes(1);
expect(devicesApiServiceUpdateTrustedDeviceKeysSpy).toHaveBeenCalledTimes(1);
@@ -508,9 +509,14 @@ describe("deviceTrustService", () => {
errorText: "rsaEncrypt error",
},
{
method: "encryptService.encrypt",
spy: () => encryptServiceEncryptSpy,
errorText: "encryptService.encrypt error",
method: "encryptService.wrapEncapsulationKey",
spy: () => encryptServiceWrapEncapsulationKeySpy,
errorText: "encryptService.wrapEncapsulationKey error",
},
{
method: "encryptService.wrapDecapsulationKey",
spy: () => encryptServiceWrapDecapsulationKeySpy,
errorText: "encryptService.wrapDecapsulationKey error",
},
];
@@ -872,7 +878,7 @@ describe("deviceTrustService", () => {
});
// Mock the reencryption of the device public key with the new user key
encryptService.encrypt.mockImplementationOnce((plainValue, key) => {
encryptService.wrapEncapsulationKey.mockImplementationOnce((plainValue, key) => {
expect(plainValue).toBeInstanceOf(Uint8Array);
expect(new Uint8Array(plainValue as Uint8Array)[0]).toBe(FakeDecryptedPublicKeyMarker);

View File

@@ -252,7 +252,9 @@ describe("KeyConnectorService", () => {
const organization = organizationData(true, true, "https://key-connector-url.com", 2, false);
const masterKey = getMockMasterKey();
masterPasswordService.masterKeySubject.next(masterKey);
const keyConnectorRequest = new KeyConnectorUserKeyRequest(masterKey.encKeyB64);
const keyConnectorRequest = new KeyConnectorUserKeyRequest(
Utils.fromBufferToB64(masterKey.inner().encryptionKey),
);
jest.spyOn(keyConnectorService, "getManagingOrganization").mockResolvedValue(organization);
jest.spyOn(apiService, "postUserKeyToKeyConnector").mockResolvedValue();
@@ -273,7 +275,9 @@ describe("KeyConnectorService", () => {
// Arrange
const organization = organizationData(true, true, "https://key-connector-url.com", 2, false);
const masterKey = getMockMasterKey();
const keyConnectorRequest = new KeyConnectorUserKeyRequest(masterKey.encKeyB64);
const keyConnectorRequest = new KeyConnectorUserKeyRequest(
Utils.fromBufferToB64(masterKey.inner().encryptionKey),
);
const error = new Error("Failed to post user key to key connector");
organizationService.organizations$.mockReturnValue(of([organization]));

View File

@@ -95,7 +95,9 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction {
userId ??= (await firstValueFrom(this.accountService.activeAccount$))?.id;
const organization = await this.getManagingOrganization(userId);
const masterKey = await firstValueFrom(this.masterPasswordService.masterKey$(userId));
const keyConnectorRequest = new KeyConnectorUserKeyRequest(masterKey.encKeyB64);
const keyConnectorRequest = new KeyConnectorUserKeyRequest(
Utils.fromBufferToB64(masterKey.inner().encryptionKey),
);
try {
await this.apiService.postUserKeyToKeyConnector(
@@ -157,7 +159,9 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction {
await this.tokenService.getEmail(),
kdfConfig,
);
const keyConnectorRequest = new KeyConnectorUserKeyRequest(masterKey.encKeyB64);
const keyConnectorRequest = new KeyConnectorUserKeyRequest(
Utils.fromBufferToB64(masterKey.inner().encryptionKey),
);
await this.masterPasswordService.setMasterKey(masterKey, userId);
const userKey = await this.keyService.makeUserKey(masterKey);

View File

@@ -1,3 +1,5 @@
import { NotificationViewResponse as EndUserNotificationResponse } from "@bitwarden/common/vault/notifications/models";
import { NotificationType } from "../../enums";
import { BaseResponse } from "./base.response";
@@ -57,6 +59,10 @@ export class NotificationResponse extends BaseResponse {
case NotificationType.SyncOrganizationCollectionSettingChanged:
this.payload = new OrganizationCollectionSettingChangedPushNotification(payload);
break;
case NotificationType.Notification:
case NotificationType.NotificationStatus:
this.payload = new EndUserNotificationResponse(payload);
break;
default:
break;
}

View File

@@ -1 +1,5 @@
// See https://contributing.bitwarden.com/architecture/clients/data-model/#view for proper use.
// View models represent the decrypted state of a corresponding Domain model.
// They typically match the Domain model but contains a decrypted string for any EncString fields.
// Don't use this to represent arbitrary component view data as that isn't what it is for.
export class View {}

View File

@@ -706,4 +706,73 @@ describe("Utils Service", () => {
});
});
});
describe("fromUtf8ToB64(...)", () => {
const originalIsNode = Utils.isNode;
afterEach(() => {
Utils.isNode = originalIsNode;
});
runInBothEnvironments("should handle empty string", () => {
const str = Utils.fromUtf8ToB64("");
expect(str).toBe("");
});
runInBothEnvironments("should convert a normal b64 string", () => {
const str = Utils.fromUtf8ToB64(asciiHelloWorld);
expect(str).toBe(b64HelloWorldString);
});
runInBothEnvironments("should convert various special characters", () => {
const cases = [
{ input: "»", output: "wrs=" },
{ input: "¦", output: "wqY=" },
{ input: "£", output: "wqM=" },
{ input: "é", output: "w6k=" },
{ input: "ö", output: "w7Y=" },
{ input: "»»", output: "wrvCuw==" },
];
cases.forEach((c) => {
const utfStr = c.input;
const str = Utils.fromUtf8ToB64(utfStr);
expect(str).toBe(c.output);
});
});
});
describe("fromB64ToUtf8(...)", () => {
const originalIsNode = Utils.isNode;
afterEach(() => {
Utils.isNode = originalIsNode;
});
runInBothEnvironments("should handle empty string", () => {
const str = Utils.fromB64ToUtf8("");
expect(str).toBe("");
});
runInBothEnvironments("should convert a normal b64 string", () => {
const str = Utils.fromB64ToUtf8(b64HelloWorldString);
expect(str).toBe(asciiHelloWorld);
});
runInBothEnvironments("should handle various special characters", () => {
const cases = [
{ input: "wrs=", output: "»" },
{ input: "wqY=", output: "¦" },
{ input: "wqM=", output: "£" },
{ input: "w6k=", output: "é" },
{ input: "w7Y=", output: "ö" },
{ input: "wrvCuw==", output: "»»" },
];
cases.forEach((c) => {
const b64Str = c.input;
const str = Utils.fromB64ToUtf8(b64Str);
expect(str).toBe(c.output);
});
});
});
});

View File

@@ -233,7 +233,7 @@ export class Utils {
if (Utils.isNode) {
return Buffer.from(utfStr, "utf8").toString("base64");
} else {
return decodeURIComponent(escape(Utils.global.btoa(utfStr)));
return BufferLib.from(utfStr, "utf8").toString("base64");
}
}
@@ -245,7 +245,7 @@ export class Utils {
if (Utils.isNode) {
return Buffer.from(b64Str, "base64").toString("utf8");
} else {
return decodeURIComponent(escape(Utils.global.atob(b64Str)));
return BufferLib.from(b64Str, "base64").toString("utf8");
}
}

View File

@@ -2,7 +2,7 @@ import { makeStaticByteArray } from "../../../../spec";
import { EncryptionType } from "../../enums";
import { Utils } from "../../misc/utils";
import { SymmetricCryptoKey } from "./symmetric-crypto-key";
import { Aes256CbcHmacKey, SymmetricCryptoKey } from "./symmetric-crypto-key";
describe("SymmetricCryptoKey", () => {
it("errors if no key", () => {
@@ -19,13 +19,8 @@ describe("SymmetricCryptoKey", () => {
const cryptoKey = new SymmetricCryptoKey(key);
expect(cryptoKey).toEqual({
encKey: key,
encKeyB64: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=",
encType: EncryptionType.AesCbc256_B64,
key: key,
keyB64: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=",
macKey: null,
macKeyB64: undefined,
innerKey: {
type: EncryptionType.AesCbc256_B64,
encryptionKey: key,
@@ -38,14 +33,9 @@ describe("SymmetricCryptoKey", () => {
const cryptoKey = new SymmetricCryptoKey(key);
expect(cryptoKey).toEqual({
encKey: key.slice(0, 32),
encKeyB64: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=",
encType: EncryptionType.AesCbc256_HmacSha256_B64,
key: key,
keyB64:
"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+Pw==",
macKey: key.slice(32, 64),
macKeyB64: "ICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj8=",
innerKey: {
type: EncryptionType.AesCbc256_HmacSha256_B64,
encryptionKey: key.slice(0, 32),
@@ -86,8 +76,8 @@ describe("SymmetricCryptoKey", () => {
expect(actual).toEqual({
type: EncryptionType.AesCbc256_HmacSha256_B64,
encryptionKey: key.encKey,
authenticationKey: key.macKey,
encryptionKey: key.inner().encryptionKey,
authenticationKey: (key.inner() as Aes256CbcHmacKey).authenticationKey,
});
});
@@ -95,7 +85,7 @@ describe("SymmetricCryptoKey", () => {
const key = new SymmetricCryptoKey(makeStaticByteArray(32));
const actual = key.toEncoded();
expect(actual).toEqual(key.encKey);
expect(actual).toEqual(key.inner().encryptionKey);
});
it("toEncoded returns encoded key for AesCbc256_HmacSha256_B64", () => {

View File

@@ -25,15 +25,7 @@ export class SymmetricCryptoKey {
private innerKey: Aes256CbcHmacKey | Aes256CbcKey;
key: Uint8Array;
encKey: Uint8Array;
macKey?: Uint8Array;
encType: EncryptionType;
keyB64: string;
encKeyB64: string;
macKeyB64: string;
meta: any;
/**
* @param key The key in one of the permitted serialization formats
@@ -48,30 +40,16 @@ export class SymmetricCryptoKey {
type: EncryptionType.AesCbc256_B64,
encryptionKey: key,
};
this.encType = EncryptionType.AesCbc256_B64;
this.key = key;
this.keyB64 = Utils.fromBufferToB64(this.key);
this.encKey = key;
this.encKeyB64 = Utils.fromBufferToB64(this.encKey);
this.macKey = null;
this.macKeyB64 = undefined;
this.keyB64 = this.toBase64();
} else if (key.byteLength === 64) {
this.innerKey = {
type: EncryptionType.AesCbc256_HmacSha256_B64,
encryptionKey: key.slice(0, 32),
authenticationKey: key.slice(32),
};
this.encType = EncryptionType.AesCbc256_HmacSha256_B64;
this.key = key;
this.keyB64 = Utils.fromBufferToB64(this.key);
this.encKey = key.slice(0, 32);
this.encKeyB64 = Utils.fromBufferToB64(this.encKey);
this.macKey = key.slice(32);
this.macKeyB64 = Utils.fromBufferToB64(this.macKey);
this.keyB64 = this.toBase64();
} else {
throw new Error(`Unsupported encType/key length ${key.byteLength}`);
}

View File

@@ -134,7 +134,7 @@ class MyWebPushConnector implements WebPushConnector {
private async pushManagerSubscribe(key: string) {
return await this.serviceWorkerRegistration.pushManager.subscribe({
userVisibleOnly: true,
userVisibleOnly: false,
applicationServerKey: key,
});
}

View File

@@ -479,7 +479,7 @@ describe("SendService", () => {
beforeEach(() => {
encryptService.decryptToBytes.mockResolvedValue(new Uint8Array(32));
encryptedKey = new EncString("Re-encrypted Send Key");
encryptService.encrypt.mockResolvedValue(encryptedKey);
encryptService.wrapSymmetricKey.mockResolvedValue(encryptedKey);
});
it("returns re-encrypted user sends", async () => {

View File

@@ -50,7 +50,7 @@ export class SendService implements InternalSendServiceAbstraction {
model: SendView,
file: File | ArrayBuffer,
password: string,
key?: SymmetricCryptoKey,
userKey?: SymmetricCryptoKey,
): Promise<[Send, EncArrayBuffer]> {
let fileData: EncArrayBuffer = null;
const send = new Send();
@@ -62,15 +62,19 @@ export class SendService implements InternalSendServiceAbstraction {
send.deletionDate = model.deletionDate;
send.expirationDate = model.expirationDate;
if (model.key == null) {
// Sends use a seed, stored in the URL fragment. This seed is used to derive the key that is used for encryption.
const key = await this.keyGenerationService.createKeyWithPurpose(
128,
this.sendKeyPurpose,
this.sendKeySalt,
);
// key.material is the seed that can be used to re-derive the key
model.key = key.material;
model.cryptoKey = key.derivedKey;
}
if (password != null) {
// Note: Despite being called key, the passwordKey is not used for encryption.
// It is used as a static proof that the client knows the password, and has the encryption key.
const passwordKey = await this.keyGenerationService.deriveKeyFromPassword(
password,
model.key,
@@ -78,10 +82,11 @@ export class SendService implements InternalSendServiceAbstraction {
);
send.password = passwordKey.keyB64;
}
if (key == null) {
key = await this.keyService.getUserKey();
if (userKey == null) {
userKey = await this.keyService.getUserKey();
}
send.key = await this.encryptService.encrypt(model.key, key);
// Key is not a SymmetricCryptoKey, but key material used to derive the cryptoKey
send.key = await this.encryptService.encrypt(model.key, userKey);
send.name = await this.encryptService.encrypt(model.name, model.cryptoKey);
send.notes = await this.encryptService.encrypt(model.notes, model.cryptoKey);
if (send.type === SendType.Text) {
@@ -287,8 +292,10 @@ export class SendService implements InternalSendServiceAbstraction {
) {
const requests = await Promise.all(
sends.map(async (send) => {
const sendKey = await this.encryptService.decryptToBytes(send.key, originalUserKey);
send.key = await this.encryptService.encrypt(sendKey, rotateUserKey);
const sendKey = new SymmetricCryptoKey(
await this.encryptService.decryptToBytes(send.key, originalUserKey),
);
send.key = await this.encryptService.wrapSymmetricKey(sendKey, rotateUserKey);
return new SendWithIdRequest(send);
}),
);

View File

@@ -0,0 +1,47 @@
import { Observable, Subscription } from "rxjs";
import { NotificationId, UserId } from "@bitwarden/common/types/guid";
import { NotificationView } from "../models";
/**
* A service for retrieving and managing notifications for end users.
*/
export abstract class EndUserNotificationService {
/**
* Observable of all notifications for the given user.
* @param userId
*/
abstract notifications$(userId: UserId): Observable<NotificationView[]>;
/**
* Observable of all unread notifications for the given user.
* @param userId
*/
abstract unreadNotifications$(userId: UserId): Observable<NotificationView[]>;
/**
* Mark a notification as read.
* @param notificationId
* @param userId
*/
abstract markAsRead(notificationId: NotificationId, userId: UserId): Promise<void>;
/**
* Mark a notification as deleted.
* @param notificationId
* @param userId
*/
abstract markAsDeleted(notificationId: NotificationId, userId: UserId): Promise<void>;
/**
* Clear all notifications from state for the given user.
* @param userId
*/
abstract clearState(userId: UserId): Promise<void>;
/**
* Creates a subscription to listen for end user push notifications and notification status updates.
*/
abstract listenForEndUserNotifications(): Subscription;
}

View File

@@ -0,0 +1,2 @@
export { EndUserNotificationService } from "./abstractions/end-user-notification.service";
export { DefaultEndUserNotificationService } from "./services/default-end-user-notification.service";

View File

@@ -0,0 +1,3 @@
export * from "./notification-view";
export * from "./notification-view.data";
export * from "./notification-view.response";

View File

@@ -0,0 +1,40 @@
import { Jsonify } from "type-fest";
import { NotificationId, SecurityTaskId } from "@bitwarden/common/types/guid";
import { NotificationViewResponse } from "./notification-view.response";
export class NotificationViewData {
id: NotificationId;
priority: number;
title: string;
body: string;
date: Date;
taskId?: SecurityTaskId;
readDate: Date | null;
deletedDate: Date | null;
constructor(response: NotificationViewResponse) {
this.id = response.id;
this.priority = response.priority;
this.title = response.title;
this.body = response.body;
this.date = response.date;
this.taskId = response.taskId;
this.readDate = response.readDate;
this.deletedDate = response.deletedDate;
}
static fromJSON(obj: Jsonify<NotificationViewData>) {
return Object.assign(new NotificationViewData({} as NotificationViewResponse), obj, {
id: obj.id,
priority: obj.priority,
title: obj.title,
body: obj.body,
date: new Date(obj.date),
taskId: obj.taskId,
readDate: obj.readDate ? new Date(obj.readDate) : null,
deletedDate: obj.deletedDate ? new Date(obj.deletedDate) : null,
});
}
}

View File

@@ -0,0 +1,25 @@
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
import { NotificationId, SecurityTaskId } from "@bitwarden/common/types/guid";
export class NotificationViewResponse extends BaseResponse {
id: NotificationId;
priority: number;
title: string;
body: string;
date: Date;
taskId?: SecurityTaskId;
readDate: Date;
deletedDate: Date;
constructor(response: any) {
super(response);
this.id = this.getResponseProperty("Id");
this.priority = this.getResponseProperty("Priority");
this.title = this.getResponseProperty("Title");
this.body = this.getResponseProperty("Body");
this.date = this.getResponseProperty("Date");
this.taskId = this.getResponseProperty("TaskId");
this.readDate = this.getResponseProperty("ReadDate");
this.deletedDate = this.getResponseProperty("DeletedDate");
}
}

View File

@@ -0,0 +1,23 @@
import { NotificationId, SecurityTaskId } from "@bitwarden/common/types/guid";
export class NotificationView {
id: NotificationId;
priority: number;
title: string;
body: string;
date: Date;
taskId?: SecurityTaskId;
readDate: Date | null;
deletedDate: Date | null;
constructor(obj: any) {
this.id = obj.id;
this.priority = obj.priority;
this.title = obj.title;
this.body = obj.body;
this.date = obj.date;
this.taskId = obj.taskId;
this.readDate = obj.readDate;
this.deletedDate = obj.deletedDate;
}
}

View File

@@ -0,0 +1,223 @@
import { mock } from "jest-mock-extended";
import { firstValueFrom, of } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { NotificationsService } from "@bitwarden/common/platform/notifications";
import { StateProvider } from "@bitwarden/common/platform/state";
import { NotificationId, UserId } from "@bitwarden/common/types/guid";
import { FakeStateProvider, mockAccountServiceWith } from "../../../../spec";
import { NotificationViewResponse } from "../models";
import { NOTIFICATIONS } from "../state/end-user-notification.state";
import {
DEFAULT_NOTIFICATION_PAGE_SIZE,
DefaultEndUserNotificationService,
} from "./default-end-user-notification.service";
describe("End User Notification Center Service", () => {
let fakeStateProvider: FakeStateProvider;
let mockApiService: jest.Mocked<ApiService>;
let mockNotificationsService: jest.Mocked<NotificationsService>;
let mockAuthService: jest.Mocked<AuthService>;
let mockLogService: jest.Mocked<LogService>;
let service: DefaultEndUserNotificationService;
beforeEach(() => {
fakeStateProvider = new FakeStateProvider(mockAccountServiceWith("user-id" as UserId));
mockApiService = {
send: jest.fn(),
} as any;
mockNotificationsService = {
notifications$: of(null),
} as any;
mockAuthService = {
authStatuses$: of({}),
} as any;
mockLogService = mock<LogService>();
service = new DefaultEndUserNotificationService(
fakeStateProvider as unknown as StateProvider,
mockApiService,
mockNotificationsService,
mockAuthService,
mockLogService,
);
});
describe("notifications$", () => {
it("should return notifications from state when not null", async () => {
fakeStateProvider.singleUser.mockFor("user-id" as UserId, NOTIFICATIONS, [
{
id: "notification-id" as NotificationId,
} as NotificationViewResponse,
]);
const result = await firstValueFrom(service.notifications$("user-id" as UserId));
expect(result.length).toBe(1);
expect(mockApiService.send).not.toHaveBeenCalled();
expect(mockLogService.warning).not.toHaveBeenCalled();
});
it("should return notifications API when state is null", async () => {
mockApiService.send.mockResolvedValue({
data: [
{
id: "notification-id",
},
] as NotificationViewResponse[],
});
fakeStateProvider.singleUser.mockFor("user-id" as UserId, NOTIFICATIONS, null as any);
const result = await firstValueFrom(service.notifications$("user-id" as UserId));
expect(result.length).toBe(1);
expect(mockApiService.send).toHaveBeenCalledWith(
"GET",
`/notifications?pageSize=${DEFAULT_NOTIFICATION_PAGE_SIZE}`,
null,
true,
true,
);
expect(mockLogService.warning).not.toHaveBeenCalled();
});
it("should log a warning if there are more notifications available", async () => {
mockApiService.send.mockResolvedValue({
data: [
...new Array(DEFAULT_NOTIFICATION_PAGE_SIZE + 1).fill({ id: "notification-id" }),
] as NotificationViewResponse[],
continuationToken: "next-token", // Presence of continuation token indicates more data
});
fakeStateProvider.singleUser.mockFor("user-id" as UserId, NOTIFICATIONS, null as any);
const result = await firstValueFrom(service.notifications$("user-id" as UserId));
expect(result.length).toBe(DEFAULT_NOTIFICATION_PAGE_SIZE + 1);
expect(mockApiService.send).toHaveBeenCalledWith(
"GET",
`/notifications?pageSize=${DEFAULT_NOTIFICATION_PAGE_SIZE}`,
null,
true,
true,
);
expect(mockLogService.warning).toHaveBeenCalledWith(
`More notifications available, but not fetched. Consider increasing the page size from ${DEFAULT_NOTIFICATION_PAGE_SIZE}`,
);
});
it("should share the same observable for the same user", async () => {
const first = service.notifications$("user-id" as UserId);
const second = service.notifications$("user-id" as UserId);
expect(first).toBe(second);
});
});
describe("unreadNotifications$", () => {
it("should return unread notifications from state when read value is null", async () => {
fakeStateProvider.singleUser.mockFor("user-id" as UserId, NOTIFICATIONS, [
{
id: "notification-id" as NotificationId,
readDate: null as any,
} as NotificationViewResponse,
]);
const result = await firstValueFrom(service.unreadNotifications$("user-id" as UserId));
expect(result.length).toBe(1);
expect(mockApiService.send).not.toHaveBeenCalled();
});
});
describe("getNotifications", () => {
it("should call getNotifications returning notifications from API", async () => {
mockApiService.send.mockResolvedValue({
data: [
{
id: "notification-id",
},
] as NotificationViewResponse[],
});
await service.refreshNotifications("user-id" as UserId);
expect(mockApiService.send).toHaveBeenCalledWith(
"GET",
`/notifications?pageSize=${DEFAULT_NOTIFICATION_PAGE_SIZE}`,
null,
true,
true,
);
});
it("should update local state when notifications are updated", async () => {
mockApiService.send.mockResolvedValue({
data: [
{
id: "notification-id",
},
] as NotificationViewResponse[],
});
const mock = fakeStateProvider.singleUser.mockFor(
"user-id" as UserId,
NOTIFICATIONS,
null as any,
);
await service.refreshNotifications("user-id" as UserId);
expect(mock.nextMock).toHaveBeenCalledWith([
expect.objectContaining({
id: "notification-id" as NotificationId,
} as NotificationViewResponse),
]);
});
});
describe("clear", () => {
it("should clear the local notification state for the user", async () => {
const mock = fakeStateProvider.singleUser.mockFor("user-id" as UserId, NOTIFICATIONS, [
{
id: "notification-id" as NotificationId,
} as NotificationViewResponse,
]);
await service.clearState("user-id" as UserId);
expect(mock.nextMock).toHaveBeenCalledWith([]);
});
});
describe("markAsDeleted", () => {
it("should send an API request to mark the notification as deleted", async () => {
await service.markAsDeleted("notification-id" as NotificationId, "user-id" as UserId);
expect(mockApiService.send).toHaveBeenCalledWith(
"DELETE",
"/notifications/notification-id/delete",
null,
true,
false,
);
});
});
describe("markAsRead", () => {
it("should send an API request to mark the notification as read", async () => {
await service.markAsRead("notification-id" as NotificationId, "user-id" as UserId);
expect(mockApiService.send).toHaveBeenCalledWith(
"PATCH",
"/notifications/notification-id/read",
null,
true,
false,
);
});
});
});

View File

@@ -0,0 +1,213 @@
import { concatMap, EMPTY, filter, map, Observable, Subscription, switchMap } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { NotificationType } from "@bitwarden/common/enums";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { NotificationsService } from "@bitwarden/common/platform/notifications";
import { StateProvider } from "@bitwarden/common/platform/state";
import { NotificationId, UserId } from "@bitwarden/common/types/guid";
import {
filterOutNullish,
perUserCache$,
} from "@bitwarden/common/vault/utils/observable-utilities";
import { EndUserNotificationService } from "../abstractions/end-user-notification.service";
import { NotificationView, NotificationViewData, NotificationViewResponse } from "../models";
import { NOTIFICATIONS } from "../state/end-user-notification.state";
/**
* The default number of notifications to fetch from the API.
*/
export const DEFAULT_NOTIFICATION_PAGE_SIZE = 50;
const getLoggedInUserIds = map<Record<UserId, AuthenticationStatus>, UserId[]>((authStatuses) =>
Object.entries(authStatuses ?? {})
.filter(([, status]) => status >= AuthenticationStatus.Locked)
.map(([userId]) => userId as UserId),
);
/**
* A service for retrieving and managing notifications for end users.
*/
export class DefaultEndUserNotificationService implements EndUserNotificationService {
constructor(
private stateProvider: StateProvider,
private apiService: ApiService,
private notificationService: NotificationsService,
private authService: AuthService,
private logService: LogService,
) {}
notifications$ = perUserCache$((userId: UserId): Observable<NotificationView[]> => {
return this.notificationState(userId).state$.pipe(
switchMap(async (notifications) => {
if (notifications == null) {
await this.fetchNotificationsFromApi(userId);
return null;
}
return notifications;
}),
filterOutNullish(),
map((notifications) =>
notifications.map((notification) => new NotificationView(notification)),
),
);
});
unreadNotifications$ = perUserCache$((userId: UserId): Observable<NotificationView[]> => {
return this.notifications$(userId).pipe(
map((notifications) => notifications.filter((notification) => notification.readDate == null)),
);
});
async markAsRead(notificationId: NotificationId, userId: UserId): Promise<void> {
await this.apiService.send("PATCH", `/notifications/${notificationId}/read`, null, true, false);
await this.notificationState(userId).update((current) => {
const notification = current?.find((n) => n.id === notificationId);
if (notification) {
notification.readDate = new Date();
}
return current;
});
}
async markAsDeleted(notificationId: NotificationId, userId: UserId): Promise<void> {
await this.apiService.send(
"DELETE",
`/notifications/${notificationId}/delete`,
null,
true,
false,
);
await this.notificationState(userId).update((current) => {
const notification = current?.find((n) => n.id === notificationId);
if (notification) {
notification.deletedDate = new Date();
}
return current;
});
}
async clearState(userId: UserId): Promise<void> {
await this.replaceNotificationState(userId, []);
}
async refreshNotifications(userId: UserId) {
await this.fetchNotificationsFromApi(userId);
}
/**
* Helper observable to filter notifications by the notification type and user ids
* Returns EMPTY if no user ids are provided
* @param userIds
* @private
*/
private filteredEndUserNotifications$(userIds: UserId[]) {
if (userIds.length == 0) {
return EMPTY;
}
return this.notificationService.notifications$.pipe(
filter(
([{ type }, userId]) =>
(type === NotificationType.Notification ||
type === NotificationType.NotificationStatus) &&
userIds.includes(userId),
),
);
}
/**
* Creates a subscription to listen for end user push notifications and notification status updates.
*/
listenForEndUserNotifications(): Subscription {
return this.authService.authStatuses$
.pipe(
getLoggedInUserIds,
switchMap((userIds) => this.filteredEndUserNotifications$(userIds)),
concatMap(([notification, userId]) =>
this.upsertNotification(
userId,
new NotificationViewData(notification.payload as NotificationViewResponse),
),
),
)
.subscribe();
}
/**
* Fetches the notifications from the API and updates the local state
* @param userId
* @private
*/
private async fetchNotificationsFromApi(userId: UserId): Promise<void> {
const res = await this.apiService.send(
"GET",
`/notifications?pageSize=${DEFAULT_NOTIFICATION_PAGE_SIZE}`,
null,
true,
true,
);
const response = new ListResponse(res, NotificationViewResponse);
if (response.continuationToken != null) {
this.logService.warning(
`More notifications available, but not fetched. Consider increasing the page size from ${DEFAULT_NOTIFICATION_PAGE_SIZE}`,
);
}
const notificationData = response.data.map((n) => new NotificationViewData(n));
await this.replaceNotificationState(userId, notificationData);
}
/**
* Replaces the local state with notifications and returns the updated state
* @param userId
* @param notifications
* @private
*/
private replaceNotificationState(
userId: UserId,
notifications: NotificationViewData[],
): Promise<NotificationViewData[] | null> {
return this.notificationState(userId).update(() => notifications);
}
/**
* Updates the local state adding the new notification or updates an existing one with the same id
* Returns the entire updated notifications state
* @param userId
* @param notification
* @private
*/
private async upsertNotification(
userId: UserId,
notification: NotificationViewData,
): Promise<NotificationViewData[] | null> {
return this.notificationState(userId).update((current) => {
current ??= [];
const existingIndex = current.findIndex((n) => n.id === notification.id);
if (existingIndex === -1) {
current.push(notification);
} else {
current[existingIndex] = notification;
}
return current;
});
}
/**
* Returns the local state for notifications
* @param userId
* @private
*/
private notificationState(userId: UserId) {
return this.stateProvider.getUser(userId, NOTIFICATIONS);
}
}

View File

@@ -0,0 +1,15 @@
import { Jsonify } from "type-fest";
import { NOTIFICATION_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state";
import { NotificationViewData } from "../models";
export const NOTIFICATIONS = UserKeyDefinition.array<NotificationViewData>(
NOTIFICATION_DISK,
"notifications",
{
deserializer: (notification: Jsonify<NotificationViewData>) =>
NotificationViewData.fromJSON(notification),
clearOn: ["logout", "lock"],
},
);

View File

@@ -280,6 +280,7 @@ describe("Cipher Service", () => {
Promise.resolve(new SymmetricCryptoKey(makeStaticByteArray(64)) as CipherKey),
);
encryptService.encrypt.mockImplementation(encryptText);
encryptService.wrapSymmetricKey.mockResolvedValue(new EncString("Re-encrypted Cipher Key"));
jest.spyOn(cipherService as any, "getAutofillOnPageLoadDefault").mockResolvedValue(true);
});
@@ -436,7 +437,7 @@ describe("Cipher Service", () => {
encryptService.decryptToBytes.mockResolvedValue(new Uint8Array(32));
encryptedKey = new EncString("Re-encrypted Cipher Key");
encryptService.encrypt.mockResolvedValue(encryptedKey);
encryptService.wrapSymmetricKey.mockResolvedValue(encryptedKey);
keyService.makeCipherKey.mockResolvedValue(
new SymmetricCryptoKey(new Uint8Array(32)) as CipherKey,

View File

@@ -124,12 +124,8 @@ export class CipherService implements CipherServiceAbstraction {
* decryption is in progress. The latest decrypted ciphers will be emitted once decryption is complete.
*/
cipherViews$ = perUserCache$((userId: UserId): Observable<CipherView[] | null> => {
return combineLatest([
this.encryptedCiphersState(userId).state$,
this.localData$(userId),
this.keyService.cipherDecryptionKeys$(userId, true),
]).pipe(
filter(([ciphers, keys]) => ciphers != null && keys != null), // Skip if ciphers haven't been loaded yor synced yet
return combineLatest([this.encryptedCiphersState(userId).state$, this.localData$(userId)]).pipe(
filter(([ciphers]) => ciphers != null), // Skip if ciphers haven't been loaded yor synced yet
switchMap(() => this.getAllDecrypted(userId)),
);
}, this.clearCipherViewsForUser$);
@@ -266,7 +262,7 @@ export class CipherService implements CipherServiceAbstraction {
key,
).then(async () => {
if (model.key != null) {
attachment.key = await this.encryptService.encrypt(model.key.key, key);
attachment.key = await this.encryptService.wrapSymmetricKey(model.key, key);
}
encAttachments.push(attachment);
});
@@ -1820,8 +1816,8 @@ export class CipherService implements CipherServiceAbstraction {
}
// Then, we have to encrypt the cipher key with the proper key.
cipher.key = await this.encryptService.encrypt(
decryptedCipherKey.key,
cipher.key = await this.encryptService.wrapSymmetricKey(
decryptedCipherKey,
keyForCipherKeyEncryption,
);