[PM-6789] finish key rotation distribution and fix legacy user (#9498)
* finish key rotation distribution and fix legacy user * add ticket to TODO * PR feedback: docs and renaming * fix webauthn tests * add test for send service * add await to test
This commit is contained in:
parent
eadb1fa4ef
commit
b306554675
|
@ -13,6 +13,7 @@ import { EncryptionType, KdfType } from "@bitwarden/common/platform/enums";
|
||||||
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||||
import { CsprngArray } from "@bitwarden/common/types/csprng";
|
import { CsprngArray } from "@bitwarden/common/types/csprng";
|
||||||
|
import { UserId } from "@bitwarden/common/types/guid";
|
||||||
import { UserKey, OrgKey, MasterKey } from "@bitwarden/common/types/key";
|
import { UserKey, OrgKey, MasterKey } from "@bitwarden/common/types/key";
|
||||||
|
|
||||||
import { OrganizationUserResetPasswordService } from "./organization-user-reset-password.service";
|
import { OrganizationUserResetPasswordService } from "./organization-user-reset-password.service";
|
||||||
|
@ -157,7 +158,7 @@ describe("OrganizationUserResetPasswordService", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getRotatedKeys", () => {
|
describe("getRotatedData", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
organizationService.getAll.mockResolvedValue([
|
organizationService.getAll.mockResolvedValue([
|
||||||
createOrganization("1", "org1"),
|
createOrganization("1", "org1"),
|
||||||
|
@ -175,12 +176,24 @@ describe("OrganizationUserResetPasswordService", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return all re-encrypted account recovery keys", async () => {
|
it("should return all re-encrypted account recovery keys", async () => {
|
||||||
const result = await sut.getRotatedKeys(
|
const result = await sut.getRotatedData(
|
||||||
new SymmetricCryptoKey(new Uint8Array(64)) as UserKey,
|
new SymmetricCryptoKey(new Uint8Array(64)) as UserKey,
|
||||||
|
new SymmetricCryptoKey(new Uint8Array(64)) as UserKey,
|
||||||
|
"mockUserId" as UserId,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result).toHaveLength(2);
|
expect(result).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("throws if the new user key is null", async () => {
|
||||||
|
await expect(
|
||||||
|
sut.getRotatedData(
|
||||||
|
new SymmetricCryptoKey(new Uint8Array(64)) as UserKey,
|
||||||
|
null,
|
||||||
|
"mockUserId" as UserId,
|
||||||
|
),
|
||||||
|
).rejects.toThrow("New user key is required for rotation.");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { Injectable } from "@angular/core";
|
import { Injectable } from "@angular/core";
|
||||||
|
|
||||||
|
import { UserKeyRotationDataProvider } from "@bitwarden/auth/common";
|
||||||
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
|
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
|
||||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||||
import { OrganizationUserService } from "@bitwarden/common/admin-console/abstractions/organization-user/organization-user.service";
|
import { OrganizationUserService } from "@bitwarden/common/admin-console/abstractions/organization-user/organization-user.service";
|
||||||
|
@ -19,12 +20,15 @@ import { KdfType } from "@bitwarden/common/platform/enums";
|
||||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||||
import { EncryptedString, EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
import { EncryptedString, EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||||
|
import { UserId } from "@bitwarden/common/types/guid";
|
||||||
import { UserKey } from "@bitwarden/common/types/key";
|
import { UserKey } from "@bitwarden/common/types/key";
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: "root",
|
providedIn: "root",
|
||||||
})
|
})
|
||||||
export class OrganizationUserResetPasswordService {
|
export class OrganizationUserResetPasswordService
|
||||||
|
implements UserKeyRotationDataProvider<OrganizationUserResetPasswordWithIdRequest>
|
||||||
|
{
|
||||||
constructor(
|
constructor(
|
||||||
private cryptoService: CryptoService,
|
private cryptoService: CryptoService,
|
||||||
private encryptService: EncryptService,
|
private encryptService: EncryptService,
|
||||||
|
@ -129,11 +133,16 @@ export class OrganizationUserResetPasswordService {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns existing account recovery keys re-encrypted with the new user key.
|
* Returns existing account recovery keys re-encrypted with the new user key.
|
||||||
|
* @param originalUserKey the original user key
|
||||||
* @param newUserKey the new user key
|
* @param newUserKey the new user key
|
||||||
|
* @param userId the user id
|
||||||
* @throws Error if new user key is null
|
* @throws Error if new user key is null
|
||||||
|
* @returns a list of account recovery keys that have been re-encrypted with the new user key
|
||||||
*/
|
*/
|
||||||
async getRotatedKeys(
|
async getRotatedData(
|
||||||
|
originalUserKey: UserKey,
|
||||||
newUserKey: UserKey,
|
newUserKey: UserKey,
|
||||||
|
userId: UserId,
|
||||||
): Promise<OrganizationUserResetPasswordWithIdRequest[] | null> {
|
): Promise<OrganizationUserResetPasswordWithIdRequest[] | null> {
|
||||||
if (newUserKey == null) {
|
if (newUserKey == null) {
|
||||||
throw new Error("New user key is required for rotation.");
|
throw new Error("New user key is required for rotation.");
|
||||||
|
|
|
@ -190,7 +190,7 @@ describe("WebauthnAdminService", () => {
|
||||||
it("should throw when old userkey is null", async () => {
|
it("should throw when old userkey is null", async () => {
|
||||||
const newUserKey = makeSymmetricCryptoKey(64) as UserKey;
|
const newUserKey = makeSymmetricCryptoKey(64) as UserKey;
|
||||||
try {
|
try {
|
||||||
await service.rotateWebAuthnKeys(null, newUserKey);
|
await service.getRotatedData(null, newUserKey, null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
expect(error).toEqual(new Error("oldUserKey is required"));
|
expect(error).toEqual(new Error("oldUserKey is required"));
|
||||||
}
|
}
|
||||||
|
@ -198,7 +198,7 @@ describe("WebauthnAdminService", () => {
|
||||||
it("should throw when new userkey is null", async () => {
|
it("should throw when new userkey is null", async () => {
|
||||||
const oldUserKey = makeSymmetricCryptoKey(64) as UserKey;
|
const oldUserKey = makeSymmetricCryptoKey(64) as UserKey;
|
||||||
try {
|
try {
|
||||||
await service.rotateWebAuthnKeys(oldUserKey, null);
|
await service.getRotatedData(oldUserKey, null, null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
expect(error).toEqual(new Error("newUserKey is required"));
|
expect(error).toEqual(new Error("newUserKey is required"));
|
||||||
}
|
}
|
||||||
|
@ -222,7 +222,7 @@ describe("WebauthnAdminService", () => {
|
||||||
.mockResolvedValue(
|
.mockResolvedValue(
|
||||||
new RotateableKeySet<PrfKey>(mockEncryptedUserKey, mockEncryptedPublicKey),
|
new RotateableKeySet<PrfKey>(mockEncryptedUserKey, mockEncryptedPublicKey),
|
||||||
);
|
);
|
||||||
await service.rotateWebAuthnKeys(oldUserKey, newUserKey);
|
await service.getRotatedData(oldUserKey, newUserKey, null);
|
||||||
expect(rotateKeySetMock).toHaveBeenCalledWith(
|
expect(rotateKeySetMock).toHaveBeenCalledWith(
|
||||||
expect.any(RotateableKeySet),
|
expect.any(RotateableKeySet),
|
||||||
oldUserKey,
|
oldUserKey,
|
||||||
|
@ -242,7 +242,7 @@ describe("WebauthnAdminService", () => {
|
||||||
],
|
],
|
||||||
} as any);
|
} as any);
|
||||||
const rotateKeySetMock = jest.spyOn(rotateableKeySetService, "rotateKeySet");
|
const rotateKeySetMock = jest.spyOn(rotateableKeySetService, "rotateKeySet");
|
||||||
await service.rotateWebAuthnKeys(oldUserKey, newUserKey);
|
await service.getRotatedData(oldUserKey, newUserKey, null);
|
||||||
expect(rotateKeySetMock).not.toHaveBeenCalled();
|
expect(rotateKeySetMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { Injectable, Optional } from "@angular/core";
|
import { Injectable, Optional } from "@angular/core";
|
||||||
import { BehaviorSubject, filter, from, map, Observable, shareReplay, switchMap, tap } from "rxjs";
|
import { BehaviorSubject, filter, from, map, Observable, shareReplay, switchMap, tap } from "rxjs";
|
||||||
|
|
||||||
import { PrfKeySet } from "@bitwarden/auth/common";
|
import { PrfKeySet, UserKeyRotationDataProvider } from "@bitwarden/auth/common";
|
||||||
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
|
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
|
||||||
import { WebAuthnLoginPrfCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-prf-crypto.service.abstraction";
|
import { WebAuthnLoginPrfCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-prf-crypto.service.abstraction";
|
||||||
import { WebauthnRotateCredentialRequest } from "@bitwarden/common/auth/models/request/webauthn-rotate-credential.request";
|
import { WebauthnRotateCredentialRequest } from "@bitwarden/common/auth/models/request/webauthn-rotate-credential.request";
|
||||||
|
@ -9,6 +9,7 @@ import { WebAuthnLoginCredentialAssertionOptionsView } from "@bitwarden/common/a
|
||||||
import { WebAuthnLoginCredentialAssertionView } from "@bitwarden/common/auth/models/view/webauthn-login/webauthn-login-credential-assertion.view";
|
import { WebAuthnLoginCredentialAssertionView } from "@bitwarden/common/auth/models/view/webauthn-login/webauthn-login-credential-assertion.view";
|
||||||
import { Verification } from "@bitwarden/common/auth/types/verification";
|
import { Verification } from "@bitwarden/common/auth/types/verification";
|
||||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||||
|
import { UserId } from "@bitwarden/common/types/guid";
|
||||||
import { UserKey } from "@bitwarden/common/types/key";
|
import { UserKey } from "@bitwarden/common/types/key";
|
||||||
|
|
||||||
import { CredentialCreateOptionsView } from "../../views/credential-create-options.view";
|
import { CredentialCreateOptionsView } from "../../views/credential-create-options.view";
|
||||||
|
@ -25,7 +26,9 @@ import { WebAuthnLoginAdminApiService } from "./webauthn-login-admin-api.service
|
||||||
/**
|
/**
|
||||||
* Service for managing WebAuthnLogin credentials.
|
* Service for managing WebAuthnLogin credentials.
|
||||||
*/
|
*/
|
||||||
export class WebauthnLoginAdminService {
|
export class WebauthnLoginAdminService
|
||||||
|
implements UserKeyRotationDataProvider<WebauthnRotateCredentialRequest>
|
||||||
|
{
|
||||||
static readonly MaxCredentialCount = 5;
|
static readonly MaxCredentialCount = 5;
|
||||||
|
|
||||||
private navigatorCredentials: CredentialsContainer;
|
private navigatorCredentials: CredentialsContainer;
|
||||||
|
@ -283,11 +286,13 @@ export class WebauthnLoginAdminService {
|
||||||
*
|
*
|
||||||
* @param oldUserKey The old user key
|
* @param oldUserKey The old user key
|
||||||
* @param newUserKey The new user key
|
* @param newUserKey The new user key
|
||||||
|
* @param userId The user id
|
||||||
* @returns A promise that returns an array of rotate credential requests when resolved.
|
* @returns A promise that returns an array of rotate credential requests when resolved.
|
||||||
*/
|
*/
|
||||||
async rotateWebAuthnKeys(
|
async getRotatedData(
|
||||||
oldUserKey: UserKey,
|
oldUserKey: UserKey,
|
||||||
newUserKey: UserKey,
|
newUserKey: UserKey,
|
||||||
|
userId: UserId,
|
||||||
): Promise<WebauthnRotateCredentialRequest[]> {
|
): Promise<WebauthnRotateCredentialRequest[]> {
|
||||||
if (!oldUserKey) {
|
if (!oldUserKey) {
|
||||||
throw new Error("oldUserKey is required");
|
throw new Error("oldUserKey is required");
|
||||||
|
|
|
@ -11,6 +11,7 @@ import { EncryptionType, KdfType } from "@bitwarden/common/platform/enums";
|
||||||
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||||
import { CsprngArray } from "@bitwarden/common/types/csprng";
|
import { CsprngArray } from "@bitwarden/common/types/csprng";
|
||||||
|
import { UserId } from "@bitwarden/common/types/guid";
|
||||||
import { UserKey, MasterKey } from "@bitwarden/common/types/key";
|
import { UserKey, MasterKey } from "@bitwarden/common/types/key";
|
||||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||||
|
|
||||||
|
@ -223,8 +224,11 @@ describe("EmergencyAccessService", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getRotatedKeys", () => {
|
describe("getRotatedData", () => {
|
||||||
let mockUserKey: UserKey;
|
const mockRandomBytes = new Uint8Array(64) as CsprngArray;
|
||||||
|
const mockOriginalUserKey = new SymmetricCryptoKey(mockRandomBytes) as UserKey;
|
||||||
|
const mockNewUserKey = new SymmetricCryptoKey(mockRandomBytes) as UserKey;
|
||||||
|
|
||||||
const allowedStatuses = [
|
const allowedStatuses = [
|
||||||
EmergencyAccessStatusType.Confirmed,
|
EmergencyAccessStatusType.Confirmed,
|
||||||
EmergencyAccessStatusType.RecoveryInitiated,
|
EmergencyAccessStatusType.RecoveryInitiated,
|
||||||
|
@ -242,9 +246,6 @@ describe("EmergencyAccessService", () => {
|
||||||
} as ListResponse<EmergencyAccessGranteeDetailsResponse>;
|
} as ListResponse<EmergencyAccessGranteeDetailsResponse>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
const mockRandomBytes = new Uint8Array(64) as CsprngArray;
|
|
||||||
mockUserKey = new SymmetricCryptoKey(mockRandomBytes) as UserKey;
|
|
||||||
|
|
||||||
emergencyAccessApiService.getEmergencyAccessTrusted.mockResolvedValue(mockEmergencyAccess);
|
emergencyAccessApiService.getEmergencyAccessTrusted.mockResolvedValue(mockEmergencyAccess);
|
||||||
apiService.getUserPublicKey.mockResolvedValue({
|
apiService.getUserPublicKey.mockResolvedValue({
|
||||||
userId: "mockUserId",
|
userId: "mockUserId",
|
||||||
|
@ -259,10 +260,20 @@ describe("EmergencyAccessService", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Only returns emergency accesses with allowed statuses", async () => {
|
it("Only returns emergency accesses with allowed statuses", async () => {
|
||||||
const result = await emergencyAccessService.getRotatedKeys(mockUserKey);
|
const result = await emergencyAccessService.getRotatedData(
|
||||||
|
mockOriginalUserKey,
|
||||||
|
mockNewUserKey,
|
||||||
|
"mockUserId" as UserId,
|
||||||
|
);
|
||||||
|
|
||||||
expect(result).toHaveLength(allowedStatuses.length);
|
expect(result).toHaveLength(allowedStatuses.length);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("throws if new user key is null", async () => {
|
||||||
|
await expect(
|
||||||
|
emergencyAccessService.getRotatedData(mockOriginalUserKey, null, "mockUserId" as UserId),
|
||||||
|
).rejects.toThrow("New user key is required for rotation.");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { Injectable } from "@angular/core";
|
import { Injectable } from "@angular/core";
|
||||||
|
|
||||||
|
import { UserKeyRotationDataProvider } from "@bitwarden/auth/common";
|
||||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||||
import { PolicyData } from "@bitwarden/common/admin-console/models/data/policy.data";
|
import { PolicyData } from "@bitwarden/common/admin-console/models/data/policy.data";
|
||||||
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
||||||
|
@ -15,6 +16,7 @@ import { KdfType } from "@bitwarden/common/platform/enums";
|
||||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||||
import { EncryptedString } from "@bitwarden/common/platform/models/domain/enc-string";
|
import { EncryptedString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||||
|
import { UserId } from "@bitwarden/common/types/guid";
|
||||||
import { UserKey } from "@bitwarden/common/types/key";
|
import { UserKey } from "@bitwarden/common/types/key";
|
||||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||||
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
|
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
|
||||||
|
@ -35,7 +37,9 @@ import {
|
||||||
import { EmergencyAccessApiService } from "./emergency-access-api.service";
|
import { EmergencyAccessApiService } from "./emergency-access-api.service";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class EmergencyAccessService {
|
export class EmergencyAccessService
|
||||||
|
implements UserKeyRotationDataProvider<EmergencyAccessWithIdRequest>
|
||||||
|
{
|
||||||
constructor(
|
constructor(
|
||||||
private emergencyAccessApiService: EmergencyAccessApiService,
|
private emergencyAccessApiService: EmergencyAccessApiService,
|
||||||
private apiService: ApiService,
|
private apiService: ApiService,
|
||||||
|
@ -286,9 +290,21 @@ export class EmergencyAccessService {
|
||||||
/**
|
/**
|
||||||
* Returns existing emergency access keys re-encrypted with new user key.
|
* Returns existing emergency access keys re-encrypted with new user key.
|
||||||
* Intended for grantor.
|
* Intended for grantor.
|
||||||
|
* @param originalUserKey the original user key
|
||||||
* @param newUserKey the new user key
|
* @param newUserKey the new user key
|
||||||
|
* @param userId the user id
|
||||||
|
* @throws Error if newUserKey is nullish
|
||||||
|
* @returns an array of re-encrypted emergency access requests or an empty array if there are no requests
|
||||||
*/
|
*/
|
||||||
async getRotatedKeys(newUserKey: UserKey): Promise<EmergencyAccessWithIdRequest[]> {
|
async getRotatedData(
|
||||||
|
originalUserKey: UserKey,
|
||||||
|
newUserKey: UserKey,
|
||||||
|
userId: UserId,
|
||||||
|
): Promise<EmergencyAccessWithIdRequest[]> {
|
||||||
|
if (newUserKey == null) {
|
||||||
|
throw new Error("New user key is required for rotation.");
|
||||||
|
}
|
||||||
|
|
||||||
const requests: EmergencyAccessWithIdRequest[] = [];
|
const requests: EmergencyAccessWithIdRequest[] = [];
|
||||||
const existingEmergencyAccess =
|
const existingEmergencyAccess =
|
||||||
await this.emergencyAccessApiService.getEmergencyAccessTrusted();
|
await this.emergencyAccessApiService.getEmergencyAccessTrusted();
|
||||||
|
|
|
@ -1,37 +1,30 @@
|
||||||
import { mock, MockProxy } from "jest-mock-extended";
|
import { mock, MockProxy } from "jest-mock-extended";
|
||||||
import { BehaviorSubject, of } from "rxjs";
|
import { BehaviorSubject } from "rxjs";
|
||||||
|
|
||||||
|
import { OrganizationUserResetPasswordWithIdRequest } from "@bitwarden/common/admin-console/abstractions/organization-user/requests";
|
||||||
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction";
|
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction";
|
||||||
import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service";
|
import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service";
|
||||||
|
import { WebauthnRotateCredentialRequest } from "@bitwarden/common/auth/models/request/webauthn-rotate-credential.request";
|
||||||
import { FakeMasterPasswordService } from "@bitwarden/common/auth/services/master-password/fake-master-password.service";
|
import { FakeMasterPasswordService } from "@bitwarden/common/auth/services/master-password/fake-master-password.service";
|
||||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||||
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
|
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
|
||||||
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
||||||
import { EncryptionType } from "@bitwarden/common/platform/enums";
|
|
||||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
|
||||||
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
|
||||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||||
import { Send } from "@bitwarden/common/tools/send/models/domain/send";
|
import { SendWithIdRequest } from "@bitwarden/common/tools/send/models/request/send-with-id.request";
|
||||||
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
|
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
|
||||||
import { UserId } from "@bitwarden/common/types/guid";
|
import { UserId } from "@bitwarden/common/types/guid";
|
||||||
import { UserKey } from "@bitwarden/common/types/key";
|
import { UserKey, UserPrivateKey } from "@bitwarden/common/types/key";
|
||||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||||
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
||||||
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
|
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
|
||||||
import { CipherType } from "@bitwarden/common/vault/enums/cipher-type";
|
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||||
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
|
import { CipherWithIdRequest } from "@bitwarden/common/vault/models/request/cipher-with-id.request";
|
||||||
import { Folder } from "@bitwarden/common/vault/models/domain/folder";
|
import { FolderWithIdRequest } from "@bitwarden/common/vault/models/request/folder-with-id.request";
|
||||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
|
||||||
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
|
|
||||||
|
|
||||||
import {
|
|
||||||
FakeAccountService,
|
|
||||||
mockAccountServiceWith,
|
|
||||||
} from "../../../../../../libs/common/spec/fake-account-service";
|
|
||||||
import { OrganizationUserResetPasswordService } from "../../admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service";
|
import { OrganizationUserResetPasswordService } from "../../admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service";
|
||||||
import { StateService } from "../../core";
|
|
||||||
import { WebauthnLoginAdminService } from "../core";
|
import { WebauthnLoginAdminService } from "../core";
|
||||||
import { EmergencyAccessService } from "../emergency-access";
|
import { EmergencyAccessService } from "../emergency-access";
|
||||||
|
import { EmergencyAccessWithIdRequest } from "../emergency-access/request/emergency-access-update.request";
|
||||||
|
|
||||||
import { UserKeyRotationApiService } from "./user-key-rotation-api.service";
|
import { UserKeyRotationApiService } from "./user-key-rotation-api.service";
|
||||||
import { UserKeyRotationService } from "./user-key-rotation.service";
|
import { UserKeyRotationService } from "./user-key-rotation.service";
|
||||||
|
@ -48,16 +41,19 @@ describe("KeyRotationService", () => {
|
||||||
let mockDeviceTrustService: MockProxy<DeviceTrustServiceAbstraction>;
|
let mockDeviceTrustService: MockProxy<DeviceTrustServiceAbstraction>;
|
||||||
let mockCryptoService: MockProxy<CryptoService>;
|
let mockCryptoService: MockProxy<CryptoService>;
|
||||||
let mockEncryptService: MockProxy<EncryptService>;
|
let mockEncryptService: MockProxy<EncryptService>;
|
||||||
let mockStateService: MockProxy<StateService>;
|
|
||||||
let mockConfigService: MockProxy<ConfigService>;
|
let mockConfigService: MockProxy<ConfigService>;
|
||||||
let mockKdfConfigService: MockProxy<KdfConfigService>;
|
let mockKdfConfigService: MockProxy<KdfConfigService>;
|
||||||
let mockSyncService: MockProxy<SyncService>;
|
let mockSyncService: MockProxy<SyncService>;
|
||||||
let mockWebauthnLoginAdminService: MockProxy<WebauthnLoginAdminService>;
|
let mockWebauthnLoginAdminService: MockProxy<WebauthnLoginAdminService>;
|
||||||
|
|
||||||
const mockUserId = Utils.newGuid() as UserId;
|
|
||||||
const mockAccountService: FakeAccountService = mockAccountServiceWith(mockUserId);
|
|
||||||
let mockMasterPasswordService: FakeMasterPasswordService = new FakeMasterPasswordService();
|
let mockMasterPasswordService: FakeMasterPasswordService = new FakeMasterPasswordService();
|
||||||
|
|
||||||
|
const mockUser = {
|
||||||
|
id: "mockUserId" as UserId,
|
||||||
|
email: "mockEmail",
|
||||||
|
emailVerified: true,
|
||||||
|
name: "mockName",
|
||||||
|
};
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
mockMasterPasswordService = new FakeMasterPasswordService();
|
mockMasterPasswordService = new FakeMasterPasswordService();
|
||||||
mockApiService = mock<UserKeyRotationApiService>();
|
mockApiService = mock<UserKeyRotationApiService>();
|
||||||
|
@ -69,7 +65,6 @@ describe("KeyRotationService", () => {
|
||||||
mockDeviceTrustService = mock<DeviceTrustServiceAbstraction>();
|
mockDeviceTrustService = mock<DeviceTrustServiceAbstraction>();
|
||||||
mockCryptoService = mock<CryptoService>();
|
mockCryptoService = mock<CryptoService>();
|
||||||
mockEncryptService = mock<EncryptService>();
|
mockEncryptService = mock<EncryptService>();
|
||||||
mockStateService = mock<StateService>();
|
|
||||||
mockConfigService = mock<ConfigService>();
|
mockConfigService = mock<ConfigService>();
|
||||||
mockKdfConfigService = mock<KdfConfigService>();
|
mockKdfConfigService = mock<KdfConfigService>();
|
||||||
mockSyncService = mock<SyncService>();
|
mockSyncService = mock<SyncService>();
|
||||||
|
@ -86,8 +81,6 @@ describe("KeyRotationService", () => {
|
||||||
mockDeviceTrustService,
|
mockDeviceTrustService,
|
||||||
mockCryptoService,
|
mockCryptoService,
|
||||||
mockEncryptService,
|
mockEncryptService,
|
||||||
mockStateService,
|
|
||||||
mockAccountService,
|
|
||||||
mockKdfConfigService,
|
mockKdfConfigService,
|
||||||
mockSyncService,
|
mockSyncService,
|
||||||
mockWebauthnLoginAdminService,
|
mockWebauthnLoginAdminService,
|
||||||
|
@ -98,91 +91,82 @@ describe("KeyRotationService", () => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("instantiates", () => {
|
|
||||||
expect(keyRotationService).not.toBeFalsy();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("rotateUserKeyAndEncryptedData", () => {
|
describe("rotateUserKeyAndEncryptedData", () => {
|
||||||
let folderViews: BehaviorSubject<FolderView[]>;
|
let privateKey: BehaviorSubject<UserPrivateKey>;
|
||||||
let sends: BehaviorSubject<Send[]>;
|
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeEach(() => {
|
||||||
mockCryptoService.makeMasterKey.mockResolvedValue("mockMasterKey" as any);
|
mockCryptoService.makeMasterKey.mockResolvedValue("mockMasterKey" as any);
|
||||||
mockCryptoService.makeUserKey.mockResolvedValue([
|
mockCryptoService.makeUserKey.mockResolvedValue([
|
||||||
new SymmetricCryptoKey(new Uint8Array(64)) as UserKey,
|
new SymmetricCryptoKey(new Uint8Array(64)) as UserKey,
|
||||||
{
|
{
|
||||||
encryptedString: "mockEncryptedUserKey",
|
encryptedString: "mockNewUserKey",
|
||||||
} as any,
|
} as any,
|
||||||
]);
|
]);
|
||||||
mockCryptoService.hashMasterKey.mockResolvedValue("mockMasterPasswordHash");
|
mockCryptoService.hashMasterKey.mockResolvedValue("mockMasterPasswordHash");
|
||||||
mockConfigService.getFeatureFlag.mockResolvedValue(true);
|
mockConfigService.getFeatureFlag.mockResolvedValue(true);
|
||||||
|
|
||||||
// Mock private key
|
|
||||||
mockCryptoService.getPrivateKey.mockResolvedValue("MockPrivateKey" as any);
|
|
||||||
mockCryptoService.userKey$.mockReturnValue(
|
|
||||||
of(new SymmetricCryptoKey(new Uint8Array(64)) as UserKey),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Mock ciphers
|
|
||||||
const mockCiphers = [createMockCipher("1", "Cipher 1"), createMockCipher("2", "Cipher 2")];
|
|
||||||
mockCipherService.getAllDecrypted.mockResolvedValue(mockCiphers);
|
|
||||||
|
|
||||||
// Mock folders
|
|
||||||
const mockFolders = [createMockFolder("1", "Folder 1"), createMockFolder("2", "Folder 2")];
|
|
||||||
folderViews = new BehaviorSubject<FolderView[]>(mockFolders);
|
|
||||||
mockFolderService.folderViews$ = folderViews;
|
|
||||||
|
|
||||||
// Mock sends
|
|
||||||
const mockSends = [createMockSend("1", "Send 1"), createMockSend("2", "Send 2")];
|
|
||||||
sends = new BehaviorSubject<Send[]>(mockSends);
|
|
||||||
mockSendService.sends$ = sends;
|
|
||||||
|
|
||||||
mockWebauthnLoginAdminService.rotateWebAuthnKeys.mockResolvedValue([]);
|
|
||||||
|
|
||||||
// Mock encryption methods
|
|
||||||
mockEncryptService.encrypt.mockResolvedValue({
|
mockEncryptService.encrypt.mockResolvedValue({
|
||||||
encryptedString: "mockEncryptedData",
|
encryptedString: "mockEncryptedData",
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
mockFolderService.encrypt.mockImplementation((folder, userKey) => {
|
// Mock user key
|
||||||
const encryptedFolder = new Folder();
|
mockCryptoService.userKey$.mockReturnValue(new BehaviorSubject("mockOriginalUserKey" as any));
|
||||||
encryptedFolder.id = folder.id;
|
|
||||||
encryptedFolder.name = new EncString(
|
|
||||||
EncryptionType.AesCbc256_HmacSha256_B64,
|
|
||||||
"Encrypted: " + folder.name,
|
|
||||||
);
|
|
||||||
return Promise.resolve(encryptedFolder);
|
|
||||||
});
|
|
||||||
|
|
||||||
mockCipherService.encrypt.mockImplementation((cipher, userKey) => {
|
// Mock private key
|
||||||
const encryptedCipher = new Cipher();
|
privateKey = new BehaviorSubject("mockPrivateKey" as any);
|
||||||
encryptedCipher.id = cipher.id;
|
mockCryptoService.userPrivateKeyWithLegacySupport$.mockReturnValue(privateKey);
|
||||||
encryptedCipher.name = new EncString(
|
|
||||||
EncryptionType.AesCbc256_HmacSha256_B64,
|
// Mock ciphers
|
||||||
"Encrypted: " + cipher.name,
|
const mockCiphers = [createMockCipher("1", "Cipher 1"), createMockCipher("2", "Cipher 2")];
|
||||||
);
|
mockCipherService.getRotatedData.mockResolvedValue(mockCiphers);
|
||||||
return Promise.resolve(encryptedCipher);
|
|
||||||
});
|
// Mock folders
|
||||||
|
const mockFolders = [createMockFolder("1", "Folder 1"), createMockFolder("2", "Folder 2")];
|
||||||
|
mockFolderService.getRotatedData.mockResolvedValue(mockFolders);
|
||||||
|
|
||||||
|
// Mock sends
|
||||||
|
const mockSends = [createMockSend("1", "Send 1"), createMockSend("2", "Send 2")];
|
||||||
|
mockSendService.getRotatedData.mockResolvedValue(mockSends);
|
||||||
|
|
||||||
|
// Mock emergency access
|
||||||
|
const emergencyAccess = [createMockEmergencyAccess("13")];
|
||||||
|
mockEmergencyAccessService.getRotatedData.mockResolvedValue(emergencyAccess);
|
||||||
|
|
||||||
|
// Mock reset password
|
||||||
|
const resetPassword = [createMockResetPassword("12")];
|
||||||
|
mockResetPasswordService.getRotatedData.mockResolvedValue(resetPassword);
|
||||||
|
|
||||||
|
// Mock Webauthn
|
||||||
|
const webauthn = [createMockWebauthn("13"), createMockWebauthn("14")];
|
||||||
|
mockWebauthnLoginAdminService.getRotatedData.mockResolvedValue(webauthn);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rotates the user key and encrypted data", async () => {
|
it("rotates the user key and encrypted data", async () => {
|
||||||
await keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword");
|
await keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword", mockUser);
|
||||||
|
|
||||||
expect(mockApiService.postUserKeyUpdate).toHaveBeenCalled();
|
expect(mockApiService.postUserKeyUpdate).toHaveBeenCalled();
|
||||||
const arg = mockApiService.postUserKeyUpdate.mock.calls[0][0];
|
const arg = mockApiService.postUserKeyUpdate.mock.calls[0][0];
|
||||||
|
expect(arg.key).toBe("mockNewUserKey");
|
||||||
|
expect(arg.privateKey).toBe("mockEncryptedData");
|
||||||
expect(arg.ciphers.length).toBe(2);
|
expect(arg.ciphers.length).toBe(2);
|
||||||
expect(arg.folders.length).toBe(2);
|
expect(arg.folders.length).toBe(2);
|
||||||
|
expect(arg.sends.length).toBe(2);
|
||||||
|
expect(arg.emergencyAccessKeys.length).toBe(1);
|
||||||
|
expect(arg.resetPasswordKeys.length).toBe(1);
|
||||||
|
expect(arg.webauthnKeys.length).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws if master password provided is falsey", async () => {
|
it("throws if master password provided is falsey", async () => {
|
||||||
await expect(keyRotationService.rotateUserKeyAndEncryptedData("")).rejects.toThrow();
|
await expect(
|
||||||
|
keyRotationService.rotateUserKeyAndEncryptedData("", mockUser),
|
||||||
|
).rejects.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws if master key creation fails", async () => {
|
it("throws if master key creation fails", async () => {
|
||||||
mockCryptoService.makeMasterKey.mockResolvedValueOnce(null);
|
mockCryptoService.makeMasterKey.mockResolvedValueOnce(null);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword"),
|
keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword", mockUser),
|
||||||
).rejects.toThrow();
|
).rejects.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -190,16 +174,24 @@ describe("KeyRotationService", () => {
|
||||||
mockCryptoService.makeUserKey.mockResolvedValueOnce([null, null]);
|
mockCryptoService.makeUserKey.mockResolvedValueOnce([null, null]);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword"),
|
keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword", mockUser),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws if no private key is found", async () => {
|
||||||
|
privateKey.next(null);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword", mockUser),
|
||||||
).rejects.toThrow();
|
).rejects.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("saves the master key in state after creation", async () => {
|
it("saves the master key in state after creation", async () => {
|
||||||
await keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword");
|
await keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword", mockUser);
|
||||||
|
|
||||||
expect(mockMasterPasswordService.mock.setMasterKey).toHaveBeenCalledWith(
|
expect(mockMasterPasswordService.mock.setMasterKey).toHaveBeenCalledWith(
|
||||||
"mockMasterKey" as any,
|
"mockMasterKey" as any,
|
||||||
mockUserId,
|
mockUser.id,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -207,30 +199,51 @@ describe("KeyRotationService", () => {
|
||||||
mockApiService.postUserKeyUpdate.mockRejectedValueOnce(new Error("mockError"));
|
mockApiService.postUserKeyUpdate.mockRejectedValueOnce(new Error("mockError"));
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword"),
|
keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword", mockUser),
|
||||||
).rejects.toThrow();
|
).rejects.toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function createMockFolder(id: string, name: string): FolderView {
|
function createMockFolder(id: string, name: string): FolderWithIdRequest {
|
||||||
const folder = new FolderView();
|
return {
|
||||||
folder.id = id;
|
id: id,
|
||||||
folder.name = name;
|
name: name,
|
||||||
return folder;
|
} as FolderWithIdRequest;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createMockCipher(id: string, name: string): CipherView {
|
function createMockCipher(id: string, name: string): CipherWithIdRequest {
|
||||||
const cipher = new CipherView();
|
return {
|
||||||
cipher.id = id;
|
id: id,
|
||||||
cipher.name = name;
|
name: name,
|
||||||
cipher.type = CipherType.Login;
|
type: CipherType.Login,
|
||||||
return cipher;
|
} as CipherWithIdRequest;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createMockSend(id: string, name: string): Send {
|
function createMockSend(id: string, name: string): SendWithIdRequest {
|
||||||
const send = new Send();
|
return {
|
||||||
send.id = id;
|
id: id,
|
||||||
send.name = new EncString(EncryptionType.AesCbc256_HmacSha256_B64, name);
|
name: name,
|
||||||
return send;
|
} as SendWithIdRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockEmergencyAccess(id: string): EmergencyAccessWithIdRequest {
|
||||||
|
return {
|
||||||
|
id: id,
|
||||||
|
type: 0,
|
||||||
|
waitTimeDays: 5,
|
||||||
|
} as EmergencyAccessWithIdRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockResetPassword(id: string): OrganizationUserResetPasswordWithIdRequest {
|
||||||
|
return {
|
||||||
|
organizationId: id,
|
||||||
|
resetPasswordKey: "mockResetPasswordKey",
|
||||||
|
} as OrganizationUserResetPasswordWithIdRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockWebauthn(id: string): any {
|
||||||
|
return {
|
||||||
|
id: id,
|
||||||
|
} as WebauthnRotateCredentialRequest;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,21 +1,19 @@
|
||||||
import { Injectable } from "@angular/core";
|
import { Injectable } from "@angular/core";
|
||||||
import { firstValueFrom, map } from "rxjs";
|
import { firstValueFrom } from "rxjs";
|
||||||
|
|
||||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
import { AccountInfo } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction";
|
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction";
|
||||||
import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service";
|
import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service";
|
||||||
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
|
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
|
||||||
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
|
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
|
||||||
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
||||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
|
||||||
import { EncryptedString } from "@bitwarden/common/platform/models/domain/enc-string";
|
import { EncryptedString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||||
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
|
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
|
||||||
|
import { UserId } from "@bitwarden/common/types/guid";
|
||||||
import { UserKey } from "@bitwarden/common/types/key";
|
import { UserKey } from "@bitwarden/common/types/key";
|
||||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||||
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
||||||
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
|
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
|
||||||
import { CipherWithIdRequest } from "@bitwarden/common/vault/models/request/cipher-with-id.request";
|
|
||||||
import { FolderWithIdRequest } from "@bitwarden/common/vault/models/request/folder-with-id.request";
|
|
||||||
|
|
||||||
import { OrganizationUserResetPasswordService } from "../../admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service";
|
import { OrganizationUserResetPasswordService } from "../../admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service";
|
||||||
import { WebauthnLoginAdminService } from "../core";
|
import { WebauthnLoginAdminService } from "../core";
|
||||||
|
@ -37,8 +35,6 @@ export class UserKeyRotationService {
|
||||||
private deviceTrustService: DeviceTrustServiceAbstraction,
|
private deviceTrustService: DeviceTrustServiceAbstraction,
|
||||||
private cryptoService: CryptoService,
|
private cryptoService: CryptoService,
|
||||||
private encryptService: EncryptService,
|
private encryptService: EncryptService,
|
||||||
private stateService: StateService,
|
|
||||||
private accountService: AccountService,
|
|
||||||
private kdfConfigService: KdfConfigService,
|
private kdfConfigService: KdfConfigService,
|
||||||
private syncService: SyncService,
|
private syncService: SyncService,
|
||||||
private webauthnLoginAdminService: WebauthnLoginAdminService,
|
private webauthnLoginAdminService: WebauthnLoginAdminService,
|
||||||
|
@ -48,7 +44,10 @@ export class UserKeyRotationService {
|
||||||
* Creates a new user key and re-encrypts all required data with the it.
|
* Creates a new user key and re-encrypts all required data with the it.
|
||||||
* @param masterPassword current master password (used for validation)
|
* @param masterPassword current master password (used for validation)
|
||||||
*/
|
*/
|
||||||
async rotateUserKeyAndEncryptedData(masterPassword: string): Promise<void> {
|
async rotateUserKeyAndEncryptedData(
|
||||||
|
masterPassword: string,
|
||||||
|
user: { id: UserId } & AccountInfo,
|
||||||
|
): Promise<void> {
|
||||||
if (!masterPassword) {
|
if (!masterPassword) {
|
||||||
throw new Error("Invalid master password");
|
throw new Error("Invalid master password");
|
||||||
}
|
}
|
||||||
|
@ -62,7 +61,7 @@ export class UserKeyRotationService {
|
||||||
// Create master key to validate the master password
|
// Create master key to validate the master password
|
||||||
const masterKey = await this.cryptoService.makeMasterKey(
|
const masterKey = await this.cryptoService.makeMasterKey(
|
||||||
masterPassword,
|
masterPassword,
|
||||||
await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a?.email))),
|
user.email,
|
||||||
await this.kdfConfigService.getKdfConfig(),
|
await this.kdfConfigService.getKdfConfig(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -71,9 +70,7 @@ export class UserKeyRotationService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set master key again in case it was lost (could be lost on refresh)
|
// Set master key again in case it was lost (could be lost on refresh)
|
||||||
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
|
await this.masterPasswordService.setMasterKey(masterKey, user.id);
|
||||||
const oldUserKey = await firstValueFrom(this.cryptoService.userKey$(userId));
|
|
||||||
await this.masterPasswordService.setMasterKey(masterKey, userId);
|
|
||||||
const [newUserKey, newEncUserKey] = await this.cryptoService.makeUserKey(masterKey);
|
const [newUserKey, newEncUserKey] = await this.cryptoService.makeUserKey(masterKey);
|
||||||
|
|
||||||
if (!newUserKey || !newEncUserKey) {
|
if (!newUserKey || !newEncUserKey) {
|
||||||
|
@ -90,61 +87,86 @@ export class UserKeyRotationService {
|
||||||
const masterPasswordHash = await this.cryptoService.hashMasterKey(masterPassword, masterKey);
|
const masterPasswordHash = await this.cryptoService.hashMasterKey(masterPassword, masterKey);
|
||||||
request.masterPasswordHash = masterPasswordHash;
|
request.masterPasswordHash = masterPasswordHash;
|
||||||
|
|
||||||
|
// Get original user key
|
||||||
|
// Note: We distribute the legacy key, but not all domains actually use it. If any of those
|
||||||
|
// domains break their legacy support it will break the migration process for legacy users.
|
||||||
|
const originalUserKey = await this.cryptoService.getUserKeyWithLegacySupport(user.id);
|
||||||
|
|
||||||
// Add re-encrypted data
|
// Add re-encrypted data
|
||||||
request.privateKey = await this.encryptPrivateKey(newUserKey);
|
request.privateKey = await this.encryptPrivateKey(newUserKey, user.id);
|
||||||
request.ciphers = await this.encryptCiphers(newUserKey);
|
|
||||||
request.folders = await this.encryptFolders(newUserKey);
|
const rotatedCiphers = await this.cipherService.getRotatedData(
|
||||||
request.sends = await this.sendService.getRotatedKeys(newUserKey);
|
originalUserKey,
|
||||||
request.emergencyAccessKeys = await this.emergencyAccessService.getRotatedKeys(newUserKey);
|
|
||||||
request.resetPasswordKeys = await this.resetPasswordService.getRotatedKeys(newUserKey);
|
|
||||||
request.webauthnKeys = await this.webauthnLoginAdminService.rotateWebAuthnKeys(
|
|
||||||
oldUserKey,
|
|
||||||
newUserKey,
|
newUserKey,
|
||||||
|
user.id,
|
||||||
);
|
);
|
||||||
|
if (rotatedCiphers != null) {
|
||||||
|
request.ciphers = rotatedCiphers;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rotatedFolders = await this.folderService.getRotatedData(
|
||||||
|
originalUserKey,
|
||||||
|
newUserKey,
|
||||||
|
user.id,
|
||||||
|
);
|
||||||
|
if (rotatedFolders != null) {
|
||||||
|
request.folders = rotatedFolders;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rotatedSends = await this.sendService.getRotatedData(
|
||||||
|
originalUserKey,
|
||||||
|
newUserKey,
|
||||||
|
user.id,
|
||||||
|
);
|
||||||
|
if (rotatedSends != null) {
|
||||||
|
request.sends = rotatedSends;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rotatedEmergencyAccessKeys = await this.emergencyAccessService.getRotatedData(
|
||||||
|
originalUserKey,
|
||||||
|
newUserKey,
|
||||||
|
user.id,
|
||||||
|
);
|
||||||
|
if (rotatedEmergencyAccessKeys != null) {
|
||||||
|
request.emergencyAccessKeys = rotatedEmergencyAccessKeys;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: Reset password keys request model has user verification
|
||||||
|
// properties, but the rotation endpoint uses its own MP hash.
|
||||||
|
const rotatedResetPasswordKeys = await this.resetPasswordService.getRotatedData(
|
||||||
|
originalUserKey,
|
||||||
|
newUserKey,
|
||||||
|
user.id,
|
||||||
|
);
|
||||||
|
if (rotatedResetPasswordKeys != null) {
|
||||||
|
request.resetPasswordKeys = rotatedResetPasswordKeys;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rotatedWebauthnKeys = await this.webauthnLoginAdminService.getRotatedData(
|
||||||
|
originalUserKey,
|
||||||
|
newUserKey,
|
||||||
|
user.id,
|
||||||
|
);
|
||||||
|
if (rotatedWebauthnKeys != null) {
|
||||||
|
request.webauthnKeys = rotatedWebauthnKeys;
|
||||||
|
}
|
||||||
|
|
||||||
await this.apiService.postUserKeyUpdate(request);
|
await this.apiService.postUserKeyUpdate(request);
|
||||||
|
|
||||||
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
|
// TODO PM-2199: Add device trust rotation support to the user key rotation endpoint
|
||||||
await this.deviceTrustService.rotateDevicesTrust(
|
await this.deviceTrustService.rotateDevicesTrust(user.id, newUserKey, masterPasswordHash);
|
||||||
activeAccount.id,
|
|
||||||
newUserKey,
|
|
||||||
masterPasswordHash,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async encryptPrivateKey(newUserKey: UserKey): Promise<EncryptedString | null> {
|
private async encryptPrivateKey(
|
||||||
const privateKey = await this.cryptoService.getPrivateKey();
|
newUserKey: UserKey,
|
||||||
|
userId: UserId,
|
||||||
|
): Promise<EncryptedString | null> {
|
||||||
|
const privateKey = await firstValueFrom(
|
||||||
|
this.cryptoService.userPrivateKeyWithLegacySupport$(userId),
|
||||||
|
);
|
||||||
if (!privateKey) {
|
if (!privateKey) {
|
||||||
return;
|
throw new Error("No private key found for user key rotation");
|
||||||
}
|
}
|
||||||
return (await this.encryptService.encrypt(privateKey, newUserKey)).encryptedString;
|
return (await this.encryptService.encrypt(privateKey, newUserKey)).encryptedString;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async encryptCiphers(newUserKey: UserKey): Promise<CipherWithIdRequest[]> {
|
|
||||||
const ciphers = await this.cipherService.getAllDecrypted();
|
|
||||||
if (!ciphers) {
|
|
||||||
// Must return an empty array for backwards compatibility
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return await Promise.all(
|
|
||||||
ciphers.map(async (cipher) => {
|
|
||||||
const encryptedCipher = await this.cipherService.encrypt(cipher, newUserKey);
|
|
||||||
return new CipherWithIdRequest(encryptedCipher);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async encryptFolders(newUserKey: UserKey): Promise<FolderWithIdRequest[]> {
|
|
||||||
const folders = await firstValueFrom(this.folderService.folderViews$);
|
|
||||||
if (!folders) {
|
|
||||||
// Must return an empty array for backwards compatibility
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return await Promise.all(
|
|
||||||
folders.map(async (folder) => {
|
|
||||||
const encryptedFolder = await this.folderService.encrypt(folder, newUserKey);
|
|
||||||
return new FolderWithIdRequest(encryptedFolder);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
import { Component } from "@angular/core";
|
import { Component } from "@angular/core";
|
||||||
import { FormControl, FormGroup, Validators } from "@angular/forms";
|
import { FormControl, FormGroup, Validators } from "@angular/forms";
|
||||||
|
import { firstValueFrom } from "rxjs";
|
||||||
|
|
||||||
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
|
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||||
|
@ -25,6 +27,7 @@ export class MigrateFromLegacyEncryptionComponent {
|
||||||
});
|
});
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
private accountService: AccountService,
|
||||||
private keyRotationService: UserKeyRotationService,
|
private keyRotationService: UserKeyRotationService,
|
||||||
private i18nService: I18nService,
|
private i18nService: I18nService,
|
||||||
private platformUtilsService: PlatformUtilsService,
|
private platformUtilsService: PlatformUtilsService,
|
||||||
|
@ -41,7 +44,9 @@ export class MigrateFromLegacyEncryptionComponent {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasUserKey = await this.cryptoService.hasUserKey();
|
const activeUser = await firstValueFrom(this.accountService.activeAccount$);
|
||||||
|
|
||||||
|
const hasUserKey = await this.cryptoService.hasUserKey(activeUser.id);
|
||||||
if (hasUserKey) {
|
if (hasUserKey) {
|
||||||
this.messagingService.send("logout");
|
this.messagingService.send("logout");
|
||||||
throw new Error("User key already exists, cannot migrate legacy encryption.");
|
throw new Error("User key already exists, cannot migrate legacy encryption.");
|
||||||
|
@ -52,7 +57,7 @@ export class MigrateFromLegacyEncryptionComponent {
|
||||||
try {
|
try {
|
||||||
await this.syncService.fullSync(false, true);
|
await this.syncService.fullSync(false, true);
|
||||||
|
|
||||||
await this.keyRotationService.rotateUserKeyAndEncryptedData(masterPassword);
|
await this.keyRotationService.rotateUserKeyAndEncryptedData(masterPassword, activeUser);
|
||||||
|
|
||||||
this.platformUtilsService.showToast(
|
this.platformUtilsService.showToast(
|
||||||
"success",
|
"success",
|
||||||
|
|
|
@ -220,6 +220,7 @@ export class ChangePasswordComponent extends BaseChangePasswordComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async updateKey() {
|
private async updateKey() {
|
||||||
await this.keyRotationService.rotateUserKeyAndEncryptedData(this.masterPassword);
|
const user = await firstValueFrom(this.accountService.activeAccount$);
|
||||||
|
await this.keyRotationService.rotateUserKeyAndEncryptedData(this.masterPassword, user);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,3 +3,4 @@ export * from "./login-email.service";
|
||||||
export * from "./login-strategy.service";
|
export * from "./login-strategy.service";
|
||||||
export * from "./user-decryption-options.service.abstraction";
|
export * from "./user-decryption-options.service.abstraction";
|
||||||
export * from "./auth-request.service.abstraction";
|
export * from "./auth-request.service.abstraction";
|
||||||
|
export * from "./user-key-rotation-data-provider.abstraction";
|
||||||
|
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { UserId } from "@bitwarden/common/types/guid";
|
||||||
|
import { UserKey } from "@bitwarden/common/types/key";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs key rotation requests for data encryption by the user key.
|
||||||
|
* @typeparam TRequest A request model that contains re-encrypted data, must have an id property
|
||||||
|
*/
|
||||||
|
export interface UserKeyRotationDataProvider<
|
||||||
|
TRequest extends { id: string } | { organizationId: string },
|
||||||
|
> {
|
||||||
|
/**
|
||||||
|
* Provides re-encrypted data for the user key rotation process
|
||||||
|
* @param originalUserKey The original user key, useful for decrypting data
|
||||||
|
* @param newUserKey The new user key to use for re-encryption
|
||||||
|
* @param userId The owner of the data, useful for fetching data
|
||||||
|
* @returns A list of data that has been re-encrypted with the new user key
|
||||||
|
*/
|
||||||
|
getRotatedData(
|
||||||
|
originalUserKey: UserKey,
|
||||||
|
newUserKey: UserKey,
|
||||||
|
userId: UserId,
|
||||||
|
): Promise<TRequest[]>;
|
||||||
|
}
|
|
@ -269,6 +269,15 @@ export abstract class CryptoService {
|
||||||
*/
|
*/
|
||||||
abstract userPrivateKey$(userId: UserId): Observable<UserPrivateKey>;
|
abstract userPrivateKey$(userId: UserId): Observable<UserPrivateKey>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets an observable stream of the given users decrypted private key with legacy support,
|
||||||
|
* will emit null if the user doesn't have a UserKey to decrypt the encrypted private key
|
||||||
|
* or null if the user doesn't have an encrypted private key at all.
|
||||||
|
*
|
||||||
|
* @param userId The user id of the user to get the data for.
|
||||||
|
*/
|
||||||
|
abstract userPrivateKeyWithLegacySupport$(userId: UserId): Observable<UserPrivateKey>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates a fingerprint phrase for the user based on their public key
|
* Generates a fingerprint phrase for the user based on their public key
|
||||||
* @param fingerprintMaterial Fingerprint material
|
* @param fingerprintMaterial Fingerprint material
|
||||||
|
|
|
@ -929,6 +929,10 @@ export class CryptoService implements CryptoServiceAbstraction {
|
||||||
return this.userPrivateKeyHelper$(userId, false).pipe(map((keys) => keys?.userPrivateKey));
|
return this.userPrivateKeyHelper$(userId, false).pipe(map((keys) => keys?.userPrivateKey));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
userPrivateKeyWithLegacySupport$(userId: UserId): Observable<UserPrivateKey> {
|
||||||
|
return this.userPrivateKeyHelper$(userId, true).pipe(map((keys) => keys?.userPrivateKey));
|
||||||
|
}
|
||||||
|
|
||||||
private userPrivateKeyHelper$(userId: UserId, legacySupport: boolean) {
|
private userPrivateKeyHelper$(userId: UserId, legacySupport: boolean) {
|
||||||
const userKey$ = legacySupport ? this.userKeyWithLegacySupport$(userId) : this.userKey$(userId);
|
const userKey$ = legacySupport ? this.userKeyWithLegacySupport$(userId) : this.userKey$(userId);
|
||||||
return userKey$.pipe(
|
return userKey$.pipe(
|
||||||
|
@ -1010,7 +1014,7 @@ export class CryptoService implements CryptoServiceAbstraction {
|
||||||
}
|
}
|
||||||
|
|
||||||
orgKeys$(userId: UserId) {
|
orgKeys$(userId: UserId) {
|
||||||
return this.cipherDecryptionKeys$(userId).pipe(map((keys) => keys?.orgKeys));
|
return this.cipherDecryptionKeys$(userId, true).pipe(map((keys) => keys?.orgKeys));
|
||||||
}
|
}
|
||||||
|
|
||||||
cipherDecryptionKeys$(
|
cipherDecryptionKeys$(
|
||||||
|
|
|
@ -1,14 +1,17 @@
|
||||||
import { Observable } from "rxjs";
|
import { Observable } from "rxjs";
|
||||||
|
|
||||||
|
import { UserKeyRotationDataProvider } from "@bitwarden/auth/common";
|
||||||
|
|
||||||
import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer";
|
import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer";
|
||||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||||
|
import { UserId } from "../../../types/guid";
|
||||||
import { UserKey } from "../../../types/key";
|
import { UserKey } from "../../../types/key";
|
||||||
import { SendData } from "../models/data/send.data";
|
import { SendData } from "../models/data/send.data";
|
||||||
import { Send } from "../models/domain/send";
|
import { Send } from "../models/domain/send";
|
||||||
import { SendWithIdRequest } from "../models/request/send-with-id.request";
|
import { SendWithIdRequest } from "../models/request/send-with-id.request";
|
||||||
import { SendView } from "../models/view/send.view";
|
import { SendView } from "../models/view/send.view";
|
||||||
|
|
||||||
export abstract class SendService {
|
export abstract class SendService implements UserKeyRotationDataProvider<SendWithIdRequest> {
|
||||||
sends$: Observable<Send[]>;
|
sends$: Observable<Send[]>;
|
||||||
sendViews$: Observable<SendView[]>;
|
sendViews$: Observable<SendView[]>;
|
||||||
|
|
||||||
|
@ -31,7 +34,11 @@ export abstract class SendService {
|
||||||
* @throws Error if the new user key is null or undefined
|
* @throws Error if the new user key is null or undefined
|
||||||
* @returns A list of user sends that have been re-encrypted with the new user key
|
* @returns A list of user sends that have been re-encrypted with the new user key
|
||||||
*/
|
*/
|
||||||
getRotatedKeys: (newUserKey: UserKey) => Promise<SendWithIdRequest[]>;
|
getRotatedData: (
|
||||||
|
originalUserKey: UserKey,
|
||||||
|
newUserKey: UserKey,
|
||||||
|
userId: UserId,
|
||||||
|
) => Promise<SendWithIdRequest[]>;
|
||||||
/**
|
/**
|
||||||
* @deprecated Do not call this, use the sends$ observable collection
|
* @deprecated Do not call this, use the sends$ observable collection
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -400,8 +400,11 @@ describe("SendService", () => {
|
||||||
expect(sends[0]).toMatchObject(testSendViewData("1", "Test Send"));
|
expect(sends[0]).toMatchObject(testSendViewData("1", "Test Send"));
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getRotatedKeys", () => {
|
describe("getRotatedData", () => {
|
||||||
|
const originalUserKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey;
|
||||||
|
const newUserKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey;
|
||||||
let encryptedKey: EncString;
|
let encryptedKey: EncString;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
encryptService.decryptToBytes.mockResolvedValue(new Uint8Array(32));
|
encryptService.decryptToBytes.mockResolvedValue(new Uint8Array(32));
|
||||||
encryptedKey = new EncString("Re-encrypted Send Key");
|
encryptedKey = new EncString("Re-encrypted Send Key");
|
||||||
|
@ -409,27 +412,30 @@ describe("SendService", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns re-encrypted user sends", async () => {
|
it("returns re-encrypted user sends", async () => {
|
||||||
const newUserKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey;
|
const result = await sendService.getRotatedData(originalUserKey, newUserKey, mockUserId);
|
||||||
const result = await sendService.getRotatedKeys(newUserKey);
|
|
||||||
|
|
||||||
expect(result).toMatchObject([{ id: "1", key: "Re-encrypted Send Key" }]);
|
expect(result).toMatchObject([{ id: "1", key: "Re-encrypted Send Key" }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns null if there are no sends", async () => {
|
it("returns empty array if there are no sends", async () => {
|
||||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
await sendService.replace(null);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
|
||||||
sendService.replace(null);
|
|
||||||
|
|
||||||
await awaitAsync();
|
await awaitAsync();
|
||||||
|
|
||||||
const newUserKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey;
|
const newUserKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey;
|
||||||
const result = await sendService.getRotatedKeys(newUserKey);
|
const result = await sendService.getRotatedData(originalUserKey, newUserKey, mockUserId);
|
||||||
|
|
||||||
expect(result).toEqual([]);
|
expect(result).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("throws if the original user key is null", async () => {
|
||||||
|
await expect(sendService.getRotatedData(null, newUserKey, mockUserId)).rejects.toThrow(
|
||||||
|
"Original user key is required for rotation.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("throws if the new user key is null", async () => {
|
it("throws if the new user key is null", async () => {
|
||||||
await expect(sendService.getRotatedKeys(null)).rejects.toThrowError(
|
await expect(sendService.getRotatedData(originalUserKey, null, mockUserId)).rejects.toThrow(
|
||||||
"New user key is required for rotation.",
|
"New user key is required for rotation.",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
@ -9,6 +9,7 @@ import { Utils } from "../../../platform/misc/utils";
|
||||||
import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer";
|
import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer";
|
||||||
import { EncString } from "../../../platform/models/domain/enc-string";
|
import { EncString } from "../../../platform/models/domain/enc-string";
|
||||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||||
|
import { UserId } from "../../../types/guid";
|
||||||
import { UserKey } from "../../../types/key";
|
import { UserKey } from "../../../types/key";
|
||||||
import { SendType } from "../enums/send-type";
|
import { SendType } from "../enums/send-type";
|
||||||
import { SendData } from "../models/data/send.data";
|
import { SendData } from "../models/data/send.data";
|
||||||
|
@ -258,12 +259,17 @@ export class SendService implements InternalSendServiceAbstraction {
|
||||||
await this.stateProvider.setEncryptedSends(sends);
|
await this.stateProvider.setEncryptedSends(sends);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getRotatedKeys(newUserKey: UserKey): Promise<SendWithIdRequest[]> {
|
async getRotatedData(
|
||||||
|
originalUserKey: UserKey,
|
||||||
|
newUserKey: UserKey,
|
||||||
|
userId: UserId,
|
||||||
|
): Promise<SendWithIdRequest[]> {
|
||||||
if (newUserKey == null) {
|
if (newUserKey == null) {
|
||||||
throw new Error("New user key is required for rotation.");
|
throw new Error("New user key is required for rotation.");
|
||||||
}
|
}
|
||||||
|
if (originalUserKey == null) {
|
||||||
const originalUserKey = await this.cryptoService.getUserKey();
|
throw new Error("Original user key is required for rotation.");
|
||||||
|
}
|
||||||
|
|
||||||
const req = await firstValueFrom(
|
const req = await firstValueFrom(
|
||||||
this.sends$.pipe(
|
this.sends$.pipe(
|
||||||
|
|
|
@ -1,19 +1,22 @@
|
||||||
import { Observable } from "rxjs";
|
import { Observable } from "rxjs";
|
||||||
|
|
||||||
|
import { UserKeyRotationDataProvider } from "@bitwarden/auth/common";
|
||||||
import { LocalData } from "@bitwarden/common/vault/models/data/local.data";
|
import { LocalData } from "@bitwarden/common/vault/models/data/local.data";
|
||||||
|
|
||||||
import { UriMatchStrategySetting } from "../../models/domain/domain-service";
|
import { UriMatchStrategySetting } from "../../models/domain/domain-service";
|
||||||
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
|
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
|
||||||
import { CipherId, CollectionId, OrganizationId } from "../../types/guid";
|
import { CipherId, CollectionId, OrganizationId, UserId } from "../../types/guid";
|
||||||
|
import { UserKey } from "../../types/key";
|
||||||
import { CipherType } from "../enums/cipher-type";
|
import { CipherType } from "../enums/cipher-type";
|
||||||
import { CipherData } from "../models/data/cipher.data";
|
import { CipherData } from "../models/data/cipher.data";
|
||||||
import { Cipher } from "../models/domain/cipher";
|
import { Cipher } from "../models/domain/cipher";
|
||||||
import { Field } from "../models/domain/field";
|
import { Field } from "../models/domain/field";
|
||||||
|
import { CipherWithIdRequest } from "../models/request/cipher-with-id.request";
|
||||||
import { CipherView } from "../models/view/cipher.view";
|
import { CipherView } from "../models/view/cipher.view";
|
||||||
import { FieldView } from "../models/view/field.view";
|
import { FieldView } from "../models/view/field.view";
|
||||||
import { AddEditCipherInfo } from "../types/add-edit-cipher-info";
|
import { AddEditCipherInfo } from "../types/add-edit-cipher-info";
|
||||||
|
|
||||||
export abstract class CipherService {
|
export abstract class CipherService implements UserKeyRotationDataProvider<CipherWithIdRequest> {
|
||||||
cipherViews$: Observable<Record<CipherId, CipherView>>;
|
cipherViews$: Observable<Record<CipherId, CipherView>>;
|
||||||
ciphers$: Observable<Record<CipherId, CipherData>>;
|
ciphers$: Observable<Record<CipherId, CipherData>>;
|
||||||
localData$: Observable<Record<CipherId, LocalData>>;
|
localData$: Observable<Record<CipherId, LocalData>>;
|
||||||
|
@ -146,4 +149,17 @@ export abstract class CipherService {
|
||||||
restoreManyWithServer: (ids: string[], orgId?: string) => Promise<void>;
|
restoreManyWithServer: (ids: string[], orgId?: string) => Promise<void>;
|
||||||
getKeyForCipherKeyDecryption: (cipher: Cipher) => Promise<any>;
|
getKeyForCipherKeyDecryption: (cipher: Cipher) => Promise<any>;
|
||||||
setAddEditCipherInfo: (value: AddEditCipherInfo) => Promise<void>;
|
setAddEditCipherInfo: (value: AddEditCipherInfo) => Promise<void>;
|
||||||
|
/**
|
||||||
|
* Returns user ciphers re-encrypted with the new user key.
|
||||||
|
* @param originalUserKey the original user key
|
||||||
|
* @param newUserKey the new user key
|
||||||
|
* @param userId the user id
|
||||||
|
* @throws Error if new user key is null
|
||||||
|
* @returns a list of user ciphers that have been re-encrypted with the new user key
|
||||||
|
*/
|
||||||
|
getRotatedData: (
|
||||||
|
originalUserKey: UserKey,
|
||||||
|
newUserKey: UserKey,
|
||||||
|
userId: UserId,
|
||||||
|
) => Promise<CipherWithIdRequest[]>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,11 +1,16 @@
|
||||||
import { Observable } from "rxjs";
|
import { Observable } from "rxjs";
|
||||||
|
|
||||||
|
import { UserKeyRotationDataProvider } from "@bitwarden/auth/common";
|
||||||
|
|
||||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||||
|
import { UserId } from "../../../types/guid";
|
||||||
|
import { UserKey } from "../../../types/key";
|
||||||
import { FolderData } from "../../models/data/folder.data";
|
import { FolderData } from "../../models/data/folder.data";
|
||||||
import { Folder } from "../../models/domain/folder";
|
import { Folder } from "../../models/domain/folder";
|
||||||
|
import { FolderWithIdRequest } from "../../models/request/folder-with-id.request";
|
||||||
import { FolderView } from "../../models/view/folder.view";
|
import { FolderView } from "../../models/view/folder.view";
|
||||||
|
|
||||||
export abstract class FolderService {
|
export abstract class FolderService implements UserKeyRotationDataProvider<FolderWithIdRequest> {
|
||||||
folders$: Observable<Folder[]>;
|
folders$: Observable<Folder[]>;
|
||||||
folderViews$: Observable<FolderView[]>;
|
folderViews$: Observable<FolderView[]>;
|
||||||
|
|
||||||
|
@ -22,6 +27,19 @@ export abstract class FolderService {
|
||||||
*/
|
*/
|
||||||
getAllDecryptedFromState: () => Promise<FolderView[]>;
|
getAllDecryptedFromState: () => Promise<FolderView[]>;
|
||||||
decryptFolders: (folders: Folder[]) => Promise<FolderView[]>;
|
decryptFolders: (folders: Folder[]) => Promise<FolderView[]>;
|
||||||
|
/**
|
||||||
|
* Returns user folders re-encrypted with the new user key.
|
||||||
|
* @param originalUserKey the original user key
|
||||||
|
* @param newUserKey the new user key
|
||||||
|
* @param userId the user id
|
||||||
|
* @throws Error if new user key is null
|
||||||
|
* @returns a list of user folders that have been re-encrypted with the new user key
|
||||||
|
*/
|
||||||
|
getRotatedData: (
|
||||||
|
originalUserKey: UserKey,
|
||||||
|
newUserKey: UserKey,
|
||||||
|
userId: UserId,
|
||||||
|
) => Promise<FolderWithIdRequest[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export abstract class InternalFolderService extends FolderService {
|
export abstract class InternalFolderService extends FolderService {
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { mock } from "jest-mock-extended";
|
import { mock } from "jest-mock-extended";
|
||||||
import { of } from "rxjs";
|
import { BehaviorSubject, of } from "rxjs";
|
||||||
|
|
||||||
import { FakeAccountService, mockAccountServiceWith } from "../../../spec/fake-account-service";
|
import { FakeAccountService, mockAccountServiceWith } from "../../../spec/fake-account-service";
|
||||||
import { FakeStateProvider } from "../../../spec/fake-state-provider";
|
import { FakeStateProvider } from "../../../spec/fake-state-provider";
|
||||||
|
@ -10,7 +10,7 @@ import { AutofillSettingsService } from "../../autofill/services/autofill-settin
|
||||||
import { DomainSettingsService } from "../../autofill/services/domain-settings.service";
|
import { DomainSettingsService } from "../../autofill/services/domain-settings.service";
|
||||||
import { UriMatchStrategy } from "../../models/domain/domain-service";
|
import { UriMatchStrategy } from "../../models/domain/domain-service";
|
||||||
import { ConfigService } from "../../platform/abstractions/config/config.service";
|
import { ConfigService } from "../../platform/abstractions/config/config.service";
|
||||||
import { CryptoService } from "../../platform/abstractions/crypto.service";
|
import { CipherDecryptionKeys, CryptoService } from "../../platform/abstractions/crypto.service";
|
||||||
import { EncryptService } from "../../platform/abstractions/encrypt.service";
|
import { EncryptService } from "../../platform/abstractions/encrypt.service";
|
||||||
import { I18nService } from "../../platform/abstractions/i18n.service";
|
import { I18nService } from "../../platform/abstractions/i18n.service";
|
||||||
import { StateService } from "../../platform/abstractions/state.service";
|
import { StateService } from "../../platform/abstractions/state.service";
|
||||||
|
@ -19,8 +19,8 @@ import { EncArrayBuffer } from "../../platform/models/domain/enc-array-buffer";
|
||||||
import { EncString } from "../../platform/models/domain/enc-string";
|
import { EncString } from "../../platform/models/domain/enc-string";
|
||||||
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
|
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
|
||||||
import { ContainerService } from "../../platform/services/container.service";
|
import { ContainerService } from "../../platform/services/container.service";
|
||||||
import { UserId } from "../../types/guid";
|
import { CipherId, UserId } from "../../types/guid";
|
||||||
import { CipherKey, OrgKey } from "../../types/key";
|
import { CipherKey, OrgKey, UserKey } from "../../types/key";
|
||||||
import { CipherFileUploadService } from "../abstractions/file-upload/cipher-file-upload.service";
|
import { CipherFileUploadService } from "../abstractions/file-upload/cipher-file-upload.service";
|
||||||
import { FieldType } from "../enums";
|
import { FieldType } from "../enums";
|
||||||
import { CipherRepromptType } from "../enums/cipher-reprompt-type";
|
import { CipherRepromptType } from "../enums/cipher-reprompt-type";
|
||||||
|
@ -331,6 +331,64 @@ describe("Cipher Service", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("getRotatedData", () => {
|
||||||
|
const originalUserKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey;
|
||||||
|
const newUserKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey;
|
||||||
|
let decryptedCiphers: BehaviorSubject<Record<CipherId, CipherView>>;
|
||||||
|
let encryptedKey: EncString;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setEncryptionKeyFlag(true);
|
||||||
|
configService.checkServerMeetsVersionRequirement$.mockReturnValue(of(true));
|
||||||
|
|
||||||
|
searchService.indexedEntityId$ = of(null);
|
||||||
|
stateService.getUserId.mockResolvedValue(mockUserId);
|
||||||
|
|
||||||
|
const keys = {
|
||||||
|
userKey: originalUserKey,
|
||||||
|
} as CipherDecryptionKeys;
|
||||||
|
cryptoService.cipherDecryptionKeys$.mockReturnValue(of(keys));
|
||||||
|
|
||||||
|
const cipher1 = new CipherView(cipherObj);
|
||||||
|
cipher1.id = "Cipher 1";
|
||||||
|
const cipher2 = new CipherView(cipherObj);
|
||||||
|
cipher2.id = "Cipher 2";
|
||||||
|
|
||||||
|
decryptedCiphers = new BehaviorSubject({
|
||||||
|
Cipher1: cipher1,
|
||||||
|
Cipher2: cipher2,
|
||||||
|
});
|
||||||
|
cipherService.cipherViews$ = decryptedCiphers;
|
||||||
|
|
||||||
|
encryptService.decryptToBytes.mockResolvedValue(new Uint8Array(32));
|
||||||
|
encryptedKey = new EncString("Re-encrypted Cipher Key");
|
||||||
|
encryptService.encrypt.mockResolvedValue(encryptedKey);
|
||||||
|
|
||||||
|
cryptoService.makeCipherKey.mockResolvedValue(
|
||||||
|
new SymmetricCryptoKey(new Uint8Array(32)) as CipherKey,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns re-encrypted user ciphers", async () => {
|
||||||
|
const result = await cipherService.getRotatedData(originalUserKey, newUserKey, mockUserId);
|
||||||
|
|
||||||
|
expect(result[0]).toMatchObject({ id: "Cipher 1", key: "Re-encrypted Cipher Key" });
|
||||||
|
expect(result[1]).toMatchObject({ id: "Cipher 2", key: "Re-encrypted Cipher Key" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws if the original user key is null", async () => {
|
||||||
|
await expect(cipherService.getRotatedData(null, newUserKey, mockUserId)).rejects.toThrow(
|
||||||
|
"Original user key is required to rotate ciphers",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws if the new user key is null", async () => {
|
||||||
|
await expect(cipherService.getRotatedData(originalUserKey, null, mockUserId)).rejects.toThrow(
|
||||||
|
"New user key is required to rotate ciphers",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function setEncryptionKeyFlag(value: boolean) {
|
function setEncryptionKeyFlag(value: boolean) {
|
||||||
|
|
|
@ -56,6 +56,7 @@ import { CipherCollectionsRequest } from "../models/request/cipher-collections.r
|
||||||
import { CipherCreateRequest } from "../models/request/cipher-create.request";
|
import { CipherCreateRequest } from "../models/request/cipher-create.request";
|
||||||
import { CipherPartialRequest } from "../models/request/cipher-partial.request";
|
import { CipherPartialRequest } from "../models/request/cipher-partial.request";
|
||||||
import { CipherShareRequest } from "../models/request/cipher-share.request";
|
import { CipherShareRequest } from "../models/request/cipher-share.request";
|
||||||
|
import { CipherWithIdRequest } from "../models/request/cipher-with-id.request";
|
||||||
import { CipherRequest } from "../models/request/cipher.request";
|
import { CipherRequest } from "../models/request/cipher.request";
|
||||||
import { CipherResponse } from "../models/response/cipher.response";
|
import { CipherResponse } from "../models/response/cipher.response";
|
||||||
import { AttachmentView } from "../models/view/attachment.view";
|
import { AttachmentView } from "../models/view/attachment.view";
|
||||||
|
@ -1168,6 +1169,34 @@ export class CipherService implements CipherServiceAbstraction {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getRotatedData(
|
||||||
|
originalUserKey: UserKey,
|
||||||
|
newUserKey: UserKey,
|
||||||
|
userId: UserId,
|
||||||
|
): Promise<CipherWithIdRequest[]> {
|
||||||
|
if (originalUserKey == null) {
|
||||||
|
throw new Error("Original user key is required to rotate ciphers");
|
||||||
|
}
|
||||||
|
if (newUserKey == null) {
|
||||||
|
throw new Error("New user key is required to rotate ciphers");
|
||||||
|
}
|
||||||
|
|
||||||
|
let encryptedCiphers: CipherWithIdRequest[] = [];
|
||||||
|
|
||||||
|
const ciphers = await this.getAllDecrypted();
|
||||||
|
if (!ciphers || ciphers.length === 0) {
|
||||||
|
return encryptedCiphers;
|
||||||
|
}
|
||||||
|
encryptedCiphers = await Promise.all(
|
||||||
|
ciphers.map(async (cipher) => {
|
||||||
|
const encryptedCipher = await this.encrypt(cipher, newUserKey, originalUserKey);
|
||||||
|
return new CipherWithIdRequest(encryptedCipher);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return encryptedCiphers;
|
||||||
|
}
|
||||||
|
|
||||||
// Helpers
|
// Helpers
|
||||||
|
|
||||||
// In the case of a cipher that is being shared with an organization, we want to decrypt the
|
// In the case of a cipher that is being shared with an organization, we want to decrypt the
|
||||||
|
|
|
@ -178,6 +178,29 @@ describe("Folder Service", () => {
|
||||||
// });
|
// });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("getRotatedData", () => {
|
||||||
|
const originalUserKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey;
|
||||||
|
const newUserKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey;
|
||||||
|
let encryptedKey: EncString;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
encryptedKey = new EncString("Re-encrypted Folder");
|
||||||
|
cryptoService.encrypt.mockResolvedValue(encryptedKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns re-encrypted user folders", async () => {
|
||||||
|
const result = await folderService.getRotatedData(originalUserKey, newUserKey, mockUserId);
|
||||||
|
|
||||||
|
expect(result[0]).toMatchObject({ id: "1", name: "Re-encrypted Folder" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws if the new user key is null", async () => {
|
||||||
|
await expect(folderService.getRotatedData(originalUserKey, null, mockUserId)).rejects.toThrow(
|
||||||
|
"New user key is required for rotation.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
function folderData(id: string, name: string) {
|
function folderData(id: string, name: string) {
|
||||||
const data = new FolderData({} as any);
|
const data = new FolderData({} as any);
|
||||||
data.id = id;
|
data.id = id;
|
||||||
|
|
|
@ -6,12 +6,14 @@ import { Utils } from "../../../platform/misc/utils";
|
||||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||||
import { ActiveUserState, DerivedState, StateProvider } from "../../../platform/state";
|
import { ActiveUserState, DerivedState, StateProvider } from "../../../platform/state";
|
||||||
import { UserId } from "../../../types/guid";
|
import { UserId } from "../../../types/guid";
|
||||||
|
import { UserKey } from "../../../types/key";
|
||||||
import { CipherService } from "../../../vault/abstractions/cipher.service";
|
import { CipherService } from "../../../vault/abstractions/cipher.service";
|
||||||
import { InternalFolderService as InternalFolderServiceAbstraction } from "../../../vault/abstractions/folder/folder.service.abstraction";
|
import { InternalFolderService as InternalFolderServiceAbstraction } from "../../../vault/abstractions/folder/folder.service.abstraction";
|
||||||
import { FolderData } from "../../../vault/models/data/folder.data";
|
import { FolderData } from "../../../vault/models/data/folder.data";
|
||||||
import { Folder } from "../../../vault/models/domain/folder";
|
import { Folder } from "../../../vault/models/domain/folder";
|
||||||
import { FolderView } from "../../../vault/models/view/folder.view";
|
import { FolderView } from "../../../vault/models/view/folder.view";
|
||||||
import { Cipher } from "../../models/domain/cipher";
|
import { Cipher } from "../../models/domain/cipher";
|
||||||
|
import { FolderWithIdRequest } from "../../models/request/folder-with-id.request";
|
||||||
import { FOLDER_DECRYPTED_FOLDERS, FOLDER_ENCRYPTED_FOLDERS } from "../key-state/folder.state";
|
import { FOLDER_DECRYPTED_FOLDERS, FOLDER_ENCRYPTED_FOLDERS } from "../key-state/folder.state";
|
||||||
|
|
||||||
export class FolderService implements InternalFolderServiceAbstraction {
|
export class FolderService implements InternalFolderServiceAbstraction {
|
||||||
|
@ -170,4 +172,27 @@ export class FolderService implements InternalFolderServiceAbstraction {
|
||||||
decryptedFolders.push(noneFolder);
|
decryptedFolders.push(noneFolder);
|
||||||
return decryptedFolders;
|
return decryptedFolders;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getRotatedData(
|
||||||
|
originalUserKey: UserKey,
|
||||||
|
newUserKey: UserKey,
|
||||||
|
userId: UserId,
|
||||||
|
): Promise<FolderWithIdRequest[]> {
|
||||||
|
if (newUserKey == null) {
|
||||||
|
throw new Error("New user key is required for rotation.");
|
||||||
|
}
|
||||||
|
|
||||||
|
let encryptedFolders: FolderWithIdRequest[] = [];
|
||||||
|
const folders = await firstValueFrom(this.folderViews$);
|
||||||
|
if (!folders) {
|
||||||
|
return encryptedFolders;
|
||||||
|
}
|
||||||
|
encryptedFolders = await Promise.all(
|
||||||
|
folders.map(async (folder) => {
|
||||||
|
const encryptedFolder = await this.encrypt(folder, newUserKey);
|
||||||
|
return new FolderWithIdRequest(encryptedFolder);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return encryptedFolders;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue