Skip to content

Commit

Permalink
[PM-3797 Part 2] Create Account Recovery Service (#6667)
Browse files Browse the repository at this point in the history
* create account recovery service

* update legacy migration tests

* declare account recovery service in migrate component

* create account recovery module

* remove changes to core organization module

* use viewContainerRef to allow dependency injection on modal

* fix imports
  • Loading branch information
jlf0dev authored Dec 1, 2023
1 parent c218767 commit 641ae84
Show file tree
Hide file tree
Showing 6 changed files with 402 additions and 214 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -11,24 +11,17 @@ import { Subject, takeUntil } from "rxjs";
import zxcvbn from "zxcvbn";

import { PasswordStrengthComponent } from "@bitwarden/angular/shared/components/password-strength/password-strength.component";
import { OrganizationUserService } from "@bitwarden/common/admin-console/abstractions/organization-user/organization-user.service";
import { OrganizationUserResetPasswordRequest } from "@bitwarden/common/admin-console/abstractions/organization-user/requests";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { KdfConfig } from "@bitwarden/common/auth/models/domain/kdf-config";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import {
SymmetricCryptoKey,
UserKey,
} from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password";
import { DialogService } from "@bitwarden/components";

import { AccountRecoveryService } from "../services/account-recovery/account-recovery.service";

@Component({
selector: "app-reset-password",
templateUrl: "reset-password.component.html",
Expand All @@ -50,13 +43,12 @@ export class ResetPasswordComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();

constructor(
private accountRecoveryService: AccountRecoveryService,
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService,
private passwordGenerationService: PasswordGenerationServiceAbstraction,
private policyService: PolicyService,
private cryptoService: CryptoService,
private logService: LogService,
private organizationUserService: OrganizationUserService,
private dialogService: DialogService,
) {}

Expand Down Expand Up @@ -151,64 +143,13 @@ export class ResetPasswordComponent implements OnInit, OnDestroy {
}
}

// Get user Information (kdf type, kdf iterations, resetPasswordKey, private key) and change password
try {
this.formPromise = this.organizationUserService
.getOrganizationUserResetPasswordDetails(this.organizationId, this.id)
.then(async (response) => {
if (response == null) {
throw new Error(this.i18nService.t("resetPasswordDetailsError"));
}

const kdfType = response.kdf;
const kdfIterations = response.kdfIterations;
const kdfMemory = response.kdfMemory;
const kdfParallelism = response.kdfParallelism;
const resetPasswordKey = response.resetPasswordKey;
const encryptedPrivateKey = response.encryptedPrivateKey;

// Decrypt Organization's encrypted Private Key with org key
const orgSymKey = await this.cryptoService.getOrgKey(this.organizationId);
const decPrivateKey = await this.cryptoService.decryptToBytes(
new EncString(encryptedPrivateKey),
orgSymKey,
);

// Decrypt User's Reset Password Key to get UserKey
const decValue = await this.cryptoService.rsaDecrypt(resetPasswordKey, decPrivateKey);
const existingUserKey = new SymmetricCryptoKey(decValue) as UserKey;

// Create new master key and hash new password
const newMasterKey = await this.cryptoService.makeMasterKey(
this.newPassword,
this.email.trim().toLowerCase(),
kdfType,
new KdfConfig(kdfIterations, kdfMemory, kdfParallelism),
);
const newMasterKeyHash = await this.cryptoService.hashMasterKey(
this.newPassword,
newMasterKey,
);

// Create new encrypted user key for the User
const newUserKey = await this.cryptoService.encryptUserKeyWithMasterKey(
newMasterKey,
existingUserKey,
);

// Create request
const request = new OrganizationUserResetPasswordRequest();
request.key = newUserKey[1].encryptedString;
request.newMasterPasswordHash = newMasterKeyHash;

// Change user's password
return this.organizationUserService.putOrganizationUserResetPassword(
this.organizationId,
this.id,
request,
);
});

this.formPromise = this.accountRecoveryService.resetMasterPassword(
this.newPassword,
this.email,
this.id,
this.organizationId,
);
await this.formPromise;
this.platformUtilsService.showToast(
"success",
Expand All @@ -219,6 +160,7 @@ export class ResetPasswordComponent implements OnInit, OnDestroy {
} catch (e) {
this.logService.error(e);
}
this.formPromise = null;
}

getStrengthResult(result: zxcvbn.ZXCVBNResult) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
import { mock, MockProxy } from "jest-mock-extended";

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 { OrganizationUserResetPasswordDetailsResponse } from "@bitwarden/common/admin-console/abstractions/organization-user/responses";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { OrganizationKeysResponse } from "@bitwarden/common/admin-console/models/response/organization-keys.response";
import { OrganizationApiService } from "@bitwarden/common/admin-console/services/organization/organization-api.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { EncryptionType, KdfType } from "@bitwarden/common/platform/enums";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import {
MasterKey,
OrgKey,
SymmetricCryptoKey,
UserKey,
} from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { CsprngArray } from "@bitwarden/common/types/csprng";

import { AccountRecoveryService } from "./account-recovery.service";

describe("AccountRecoveryService", () => {
let sut: AccountRecoveryService;

let cryptoService: MockProxy<CryptoService>;
let encryptService: MockProxy<EncryptService>;
let organizationService: MockProxy<OrganizationService>;
let organizationUserService: MockProxy<OrganizationUserService>;
let organizationApiService: MockProxy<OrganizationApiService>;
let i18nService: MockProxy<I18nService>;

beforeAll(() => {
cryptoService = mock<CryptoService>();
encryptService = mock<EncryptService>();
organizationService = mock<OrganizationService>();
organizationUserService = mock<OrganizationUserService>();
organizationApiService = mock<OrganizationApiService>();
i18nService = mock<I18nService>();

sut = new AccountRecoveryService(
cryptoService,
encryptService,
organizationService,
organizationUserService,
organizationApiService,
i18nService,
);
});

afterEach(() => {
jest.resetAllMocks();
});

it("should be created", () => {
expect(sut).toBeTruthy();
});

describe("getRecoveryKey", () => {
const mockOrgId = "test-org-id";

beforeEach(() => {
organizationApiService.getKeys.mockResolvedValue(
new OrganizationKeysResponse({
privateKey: "test-private-key",
publicKey: "test-public-key",
}),
);

const mockRandomBytes = new Uint8Array(64) as CsprngArray;
const mockUserKey = new SymmetricCryptoKey(mockRandomBytes) as UserKey;
cryptoService.getUserKey.mockResolvedValue(mockUserKey);

cryptoService.rsaEncrypt.mockResolvedValue(
new EncString(EncryptionType.Rsa2048_OaepSha1_B64, "mockEncryptedUserKey"),
);
});

it("should return an encrypted user key", async () => {
const encryptedString = await sut.buildRecoveryKey(mockOrgId);
expect(encryptedString).toBeDefined();
});

it("should only use the user key from memory if one is not provided", async () => {
const mockRandomBytes = new Uint8Array(64) as CsprngArray;
const mockUserKey = new SymmetricCryptoKey(mockRandomBytes) as UserKey;

await sut.buildRecoveryKey(mockOrgId, mockUserKey);

expect(cryptoService.getUserKey).not.toHaveBeenCalled();
});

it("should throw an error if the organization keys are null", async () => {
organizationApiService.getKeys.mockResolvedValue(null);
await expect(sut.buildRecoveryKey(mockOrgId)).rejects.toThrow();
});

it("should throw an error if the user key can't be found", async () => {
cryptoService.getUserKey.mockResolvedValue(null);
await expect(sut.buildRecoveryKey(mockOrgId)).rejects.toThrow();
});

it("should rsa encrypt the user key", async () => {
await sut.buildRecoveryKey(mockOrgId);

expect(cryptoService.rsaEncrypt).toHaveBeenCalledWith(expect.anything(), expect.anything());
});
});

describe("resetMasterPassword", () => {
const mockNewMP = "new-password";
const mockEmail = "test@example.com";
const mockOrgUserId = "test-org-user-id";
const mockOrgId = "test-org-id";

beforeEach(() => {
organizationUserService.getOrganizationUserResetPasswordDetails.mockResolvedValue(
new OrganizationUserResetPasswordDetailsResponse({
kdf: KdfType.PBKDF2_SHA256,
kdfIterations: 5000,
resetPasswordKey: "test-reset-password-key",
encryptedPrivateKey: "test-encrypted-private-key",
}),
);

const mockRandomBytes = new Uint8Array(64) as CsprngArray;
const mockOrgKey = new SymmetricCryptoKey(mockRandomBytes) as OrgKey;
cryptoService.getOrgKey.mockResolvedValue(mockOrgKey);
encryptService.decryptToBytes.mockResolvedValue(mockRandomBytes);

cryptoService.rsaDecrypt.mockResolvedValue(mockRandomBytes);
const mockMasterKey = new SymmetricCryptoKey(mockRandomBytes) as MasterKey;
cryptoService.makeMasterKey.mockResolvedValue(mockMasterKey);
cryptoService.hashMasterKey.mockResolvedValue("test-master-key-hash");

const mockUserKey = new SymmetricCryptoKey(mockRandomBytes) as UserKey;
cryptoService.encryptUserKeyWithMasterKey.mockResolvedValue([
mockUserKey,
new EncString(EncryptionType.AesCbc256_HmacSha256_B64, "test-encrypted-user-key"),
]);
});

it("should reset the user's master password", async () => {
await sut.resetMasterPassword(mockNewMP, mockEmail, mockOrgUserId, mockOrgId);
expect(organizationUserService.putOrganizationUserResetPassword).toHaveBeenCalled();
});

it("should throw an error if the user details are null", async () => {
organizationUserService.getOrganizationUserResetPasswordDetails.mockResolvedValue(null);
await expect(
sut.resetMasterPassword(mockNewMP, mockEmail, mockOrgUserId, mockOrgId),
).rejects.toThrow();
});

it("should throw an error if the org key is null", async () => {
cryptoService.getOrgKey.mockResolvedValue(null);
await expect(
sut.resetMasterPassword(mockNewMP, mockEmail, mockOrgUserId, mockOrgId),
).rejects.toThrow();
});
});

describe("rotate", () => {
beforeEach(() => {
organizationService.getAll.mockResolvedValue([
createOrganization("1", "org1"),
createOrganization("2", "org2"),
]);
organizationApiService.getKeys.mockResolvedValue(
new OrganizationKeysResponse({
privateKey: "test-private-key",
publicKey: "test-public-key",
}),
);
cryptoService.rsaEncrypt.mockResolvedValue(
new EncString(EncryptionType.Rsa2048_OaepSha1_B64, "mockEncryptedUserKey"),
);
});

it("should rotate all of the user's recovery key", async () => {
organizationApiService.getKeys.mockResolvedValue(
new OrganizationKeysResponse({
privateKey: "test-private-key",
publicKey: "test-public-key",
}),
);
cryptoService.rsaEncrypt.mockResolvedValue(
new EncString(EncryptionType.Rsa2048_OaepSha1_B64, "mockEncryptedUserKey"),
);
organizationService.getAll.mockResolvedValue([
createOrganization("1", "org1"),
createOrganization("2", "org2"),
]);

await sut.rotate(
new SymmetricCryptoKey(new Uint8Array(64)) as UserKey,
"test-master-password-hash",
);

expect(
organizationUserService.putOrganizationUserResetPasswordEnrollment,
).toHaveBeenCalledTimes(2);
});
});
});

function createOrganization(id: string, name: string) {
const org = new Organization();
org.id = id;
org.name = name;
org.identifier = name;
org.isMember = true;
org.resetPasswordEnrolled = true;
return org;
}
Loading

0 comments on commit 641ae84

Please sign in to comment.