1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +00:00

[PM-23619] Remove getPrivateKey from the key service and update consumers (#15784)

* remove getPrivateKey from keyService

* Update consumer code

* Increase unit test coverage
This commit is contained in:
Thomas Avery
2025-08-05 09:25:50 -05:00
committed by GitHub
parent 7145092889
commit 2a3e1ae1f5
7 changed files with 358 additions and 143 deletions

View File

@@ -2,6 +2,7 @@
// @ts-strict-ignore // @ts-strict-ignore
import { MockProxy } from "jest-mock-extended"; import { MockProxy } from "jest-mock-extended";
import mock from "jest-mock-extended/lib/Mock"; import mock from "jest-mock-extended/lib/Mock";
import { of } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
@@ -14,9 +15,9 @@ import { Utils } from "@bitwarden/common/platform/misc/utils";
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 { UserId } from "@bitwarden/common/types/guid";
import { UserKey, MasterKey } from "@bitwarden/common/types/key"; import { UserKey, MasterKey, 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 { KdfType, KeyService } from "@bitwarden/key-management"; import { Argon2KdfConfig, KdfType, KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management";
import { EmergencyAccessStatusType } from "../enums/emergency-access-status-type"; import { EmergencyAccessStatusType } from "../enums/emergency-access-status-type";
import { EmergencyAccessType } from "../enums/emergency-access-type"; import { EmergencyAccessType } from "../enums/emergency-access-type";
@@ -26,6 +27,7 @@ import {
EmergencyAccessGranteeDetailsResponse, EmergencyAccessGranteeDetailsResponse,
EmergencyAccessGrantorDetailsResponse, EmergencyAccessGrantorDetailsResponse,
EmergencyAccessTakeoverResponse, EmergencyAccessTakeoverResponse,
EmergencyAccessViewResponse,
} from "../response/emergency-access.response"; } from "../response/emergency-access.response";
import { EmergencyAccessApiService } from "./emergency-access-api.service"; import { EmergencyAccessApiService } from "./emergency-access-api.service";
@@ -142,88 +144,306 @@ describe("EmergencyAccessService", () => {
}); });
}); });
describe("getViewOnlyCiphers", () => {
const params = {
id: "emergency-access-id",
activeUserId: Utils.newGuid() as UserId,
};
it("throws an error is the active user's private key isn't available", async () => {
keyService.userPrivateKey$.mockReturnValue(of(null));
await expect(
emergencyAccessService.getViewOnlyCiphers(params.id, params.activeUserId),
).rejects.toThrow("Active user does not have a private key, cannot get view only ciphers.");
});
it("should return decrypted and sorted ciphers", async () => {
const emergencyAccessViewResponse = {
keyEncrypted: "mockKeyEncrypted",
ciphers: [
{ id: "cipher1", name: "encryptedName1" },
{ id: "cipher2", name: "encryptedName2" },
],
} as EmergencyAccessViewResponse;
const mockEncryptedCipher1 = {
id: "cipher1",
decrypt: jest.fn().mockResolvedValue({ id: "cipher1", decrypted: true }),
};
const mockEncryptedCipher2 = {
id: "cipher2",
decrypt: jest.fn().mockResolvedValue({ id: "cipher2", decrypted: true }),
};
emergencyAccessViewResponse.ciphers.map = jest.fn().mockImplementation(() => {
return [mockEncryptedCipher1, mockEncryptedCipher2];
});
cipherService.getLocaleSortingFunction.mockReturnValue((a: any, b: any) =>
a.id.localeCompare(b.id),
);
emergencyAccessApiService.postEmergencyAccessView.mockResolvedValue(
emergencyAccessViewResponse,
);
const mockPrivateKey = new Uint8Array(64) as UserPrivateKey;
keyService.userPrivateKey$.mockReturnValue(of(mockPrivateKey));
const mockDecryptedGrantorUserKey = new SymmetricCryptoKey(new Uint8Array(64));
encryptService.decapsulateKeyUnsigned.mockResolvedValueOnce(mockDecryptedGrantorUserKey);
const mockGrantorUserKey = mockDecryptedGrantorUserKey as UserKey;
const result = await emergencyAccessService.getViewOnlyCiphers(
params.id,
params.activeUserId,
);
expect(result).toEqual([
{ id: "cipher1", decrypted: true },
{ id: "cipher2", decrypted: true },
]);
expect(mockEncryptedCipher1.decrypt).toHaveBeenCalledWith(mockGrantorUserKey);
expect(mockEncryptedCipher2.decrypt).toHaveBeenCalledWith(mockGrantorUserKey);
expect(emergencyAccessApiService.postEmergencyAccessView).toHaveBeenCalledWith(params.id);
expect(keyService.userPrivateKey$).toHaveBeenCalledWith(params.activeUserId);
expect(encryptService.decapsulateKeyUnsigned).toHaveBeenCalledWith(
new EncString(emergencyAccessViewResponse.keyEncrypted),
mockPrivateKey,
);
expect(cipherService.getLocaleSortingFunction).toHaveBeenCalled();
});
});
describe("takeover", () => { describe("takeover", () => {
const mockId = "emergencyAccessId"; const params = {
const mockEmail = "emergencyAccessEmail"; id: "emergencyAccessId",
const mockName = "emergencyAccessName"; masterPassword: "mockPassword",
email: "emergencyAccessEmail",
activeUserId: Utils.newGuid() as UserId,
};
const takeoverResponse = {
keyEncrypted: "EncryptedKey",
kdf: KdfType.PBKDF2_SHA256,
kdfIterations: 500,
} as EmergencyAccessTakeoverResponse;
const userPrivateKey = new Uint8Array(64) as UserPrivateKey;
const mockMasterKey = new SymmetricCryptoKey(new Uint8Array(64) as CsprngArray) as MasterKey;
const mockMasterKeyHash = "mockMasterKeyHash";
let mockGrantorUserKey: UserKey;
// must mock [UserKey, EncString] return from keyService.encryptUserKeyWithMasterKey
// where UserKey is the decrypted grantor user key
const mockMasterKeyEncryptedUserKey = new EncString(
EncryptionType.AesCbc256_HmacSha256_B64,
"mockMasterKeyEncryptedUserKey",
);
beforeEach(() => {
emergencyAccessApiService.postEmergencyAccessTakeover.mockResolvedValueOnce(takeoverResponse);
keyService.userPrivateKey$.mockReturnValue(of(userPrivateKey));
const mockDecryptedGrantorUserKey = new SymmetricCryptoKey(new Uint8Array(64));
encryptService.decapsulateKeyUnsigned.mockResolvedValueOnce(mockDecryptedGrantorUserKey);
mockGrantorUserKey = mockDecryptedGrantorUserKey as UserKey;
keyService.makeMasterKey.mockResolvedValueOnce(mockMasterKey);
keyService.hashMasterKey.mockResolvedValueOnce(mockMasterKeyHash);
keyService.encryptUserKeyWithMasterKey.mockResolvedValueOnce([
mockGrantorUserKey,
mockMasterKeyEncryptedUserKey,
]);
});
it("posts a new password when decryption succeeds", async () => { it("posts a new password when decryption succeeds", async () => {
// Arrange // Arrange
emergencyAccessApiService.postEmergencyAccessTakeover.mockResolvedValueOnce({ const expectedKdfConfig = new PBKDF2KdfConfig(takeoverResponse.kdfIterations);
keyEncrypted: "EncryptedKey",
kdf: KdfType.PBKDF2_SHA256,
kdfIterations: 500,
} as EmergencyAccessTakeoverResponse);
const mockDecryptedGrantorUserKey = new Uint8Array(64);
keyService.getPrivateKey.mockResolvedValue(new Uint8Array(64));
encryptService.decapsulateKeyUnsigned.mockResolvedValueOnce(
new SymmetricCryptoKey(mockDecryptedGrantorUserKey),
);
const mockMasterKey = new SymmetricCryptoKey(new Uint8Array(64) as CsprngArray) as MasterKey;
keyService.makeMasterKey.mockResolvedValueOnce(mockMasterKey);
const mockMasterKeyHash = "mockMasterKeyHash";
keyService.hashMasterKey.mockResolvedValueOnce(mockMasterKeyHash);
// must mock [UserKey, EncString] return from keyService.encryptUserKeyWithMasterKey
// where UserKey is the decrypted grantor user key
const mockMasterKeyEncryptedUserKey = new EncString(
EncryptionType.AesCbc256_HmacSha256_B64,
"mockMasterKeyEncryptedUserKey",
);
const mockUserKey = new SymmetricCryptoKey(mockDecryptedGrantorUserKey) as UserKey;
keyService.encryptUserKeyWithMasterKey.mockResolvedValueOnce([
mockUserKey,
mockMasterKeyEncryptedUserKey,
]);
const expectedEmergencyAccessPasswordRequest = new EmergencyAccessPasswordRequest(); const expectedEmergencyAccessPasswordRequest = new EmergencyAccessPasswordRequest();
expectedEmergencyAccessPasswordRequest.newMasterPasswordHash = mockMasterKeyHash; expectedEmergencyAccessPasswordRequest.newMasterPasswordHash = mockMasterKeyHash;
expectedEmergencyAccessPasswordRequest.key = mockMasterKeyEncryptedUserKey.encryptedString; expectedEmergencyAccessPasswordRequest.key = mockMasterKeyEncryptedUserKey.encryptedString;
// Act // Act
await emergencyAccessService.takeover(mockId, mockEmail, mockName); await emergencyAccessService.takeover(
params.id,
params.masterPassword,
params.email,
params.activeUserId,
);
// Assert // Assert
expect(keyService.userPrivateKey$).toHaveBeenCalledWith(params.activeUserId);
expect(encryptService.decapsulateKeyUnsigned).toHaveBeenCalledWith(
new EncString(takeoverResponse.keyEncrypted),
userPrivateKey,
);
expect(keyService.makeMasterKey).toHaveBeenCalledWith(
params.masterPassword,
params.email,
expectedKdfConfig,
);
expect(keyService.hashMasterKey).toHaveBeenCalledWith(params.masterPassword, mockMasterKey);
expect(keyService.encryptUserKeyWithMasterKey).toHaveBeenCalledWith(
mockMasterKey,
mockGrantorUserKey,
);
expect(emergencyAccessApiService.postEmergencyAccessPassword).toHaveBeenCalledWith( expect(emergencyAccessApiService.postEmergencyAccessPassword).toHaveBeenCalledWith(
mockId, params.id,
expectedEmergencyAccessPasswordRequest, expectedEmergencyAccessPasswordRequest,
); );
}); });
it("should not post a new password if decryption fails", async () => { it("uses argon2 KDF if takeover response is argon2", async () => {
encryptService.rsaDecrypt.mockResolvedValueOnce(null); const argon2TakeoverResponse = {
emergencyAccessApiService.postEmergencyAccessTakeover.mockResolvedValueOnce({
keyEncrypted: "EncryptedKey", keyEncrypted: "EncryptedKey",
kdf: KdfType.PBKDF2_SHA256, kdf: KdfType.Argon2id,
kdfIterations: 500, kdfIterations: 3,
} as EmergencyAccessTakeoverResponse); kdfMemory: 64,
keyService.getPrivateKey.mockResolvedValue(new Uint8Array(64)); kdfParallelism: 4,
} as EmergencyAccessTakeoverResponse;
emergencyAccessApiService.postEmergencyAccessTakeover.mockReset();
emergencyAccessApiService.postEmergencyAccessTakeover.mockResolvedValueOnce(
argon2TakeoverResponse,
);
const expectedKdfConfig = new Argon2KdfConfig(
argon2TakeoverResponse.kdfIterations,
argon2TakeoverResponse.kdfMemory,
argon2TakeoverResponse.kdfParallelism,
);
const expectedEmergencyAccessPasswordRequest = new EmergencyAccessPasswordRequest();
expectedEmergencyAccessPasswordRequest.newMasterPasswordHash = mockMasterKeyHash;
expectedEmergencyAccessPasswordRequest.key = mockMasterKeyEncryptedUserKey.encryptedString;
await emergencyAccessService.takeover(
params.id,
params.masterPassword,
params.email,
params.activeUserId,
);
expect(keyService.userPrivateKey$).toHaveBeenCalledWith(params.activeUserId);
expect(encryptService.decapsulateKeyUnsigned).toHaveBeenCalledWith(
new EncString(argon2TakeoverResponse.keyEncrypted),
userPrivateKey,
);
expect(keyService.makeMasterKey).toHaveBeenCalledWith(
params.masterPassword,
params.email,
expectedKdfConfig,
);
expect(keyService.hashMasterKey).toHaveBeenCalledWith(params.masterPassword, mockMasterKey);
expect(keyService.encryptUserKeyWithMasterKey).toHaveBeenCalledWith(
mockMasterKey,
mockGrantorUserKey,
);
expect(emergencyAccessApiService.postEmergencyAccessPassword).toHaveBeenCalledWith(
params.id,
expectedEmergencyAccessPasswordRequest,
);
});
it("throws an error if masterKeyEncryptedUserKey is not found", async () => {
keyService.encryptUserKeyWithMasterKey.mockReset();
keyService.encryptUserKeyWithMasterKey.mockResolvedValueOnce(null);
const expectedKdfConfig = new PBKDF2KdfConfig(takeoverResponse.kdfIterations);
await expect( await expect(
emergencyAccessService.takeover(mockId, mockEmail, mockName), emergencyAccessService.takeover(
).rejects.toThrowError("Failed to decrypt grantor key"); params.id,
params.masterPassword,
params.email,
params.activeUserId,
),
).rejects.toThrow("masterKeyEncryptedUserKey not found");
expect(keyService.userPrivateKey$).toHaveBeenCalledWith(params.activeUserId);
expect(encryptService.decapsulateKeyUnsigned).toHaveBeenCalledWith(
new EncString(takeoverResponse.keyEncrypted),
userPrivateKey,
);
expect(keyService.makeMasterKey).toHaveBeenCalledWith(
params.masterPassword,
params.email,
expectedKdfConfig,
);
expect(keyService.hashMasterKey).toHaveBeenCalledWith(params.masterPassword, mockMasterKey);
expect(keyService.encryptUserKeyWithMasterKey).toHaveBeenCalledWith(
mockMasterKey,
mockGrantorUserKey,
);
expect(emergencyAccessApiService.postEmergencyAccessPassword).not.toHaveBeenCalled();
});
it("should not post a new password if decryption fails", async () => {
emergencyAccessApiService.postEmergencyAccessTakeover.mockResolvedValueOnce(takeoverResponse);
encryptService.decapsulateKeyUnsigned.mockReset();
encryptService.decapsulateKeyUnsigned.mockResolvedValueOnce(null);
await expect(
emergencyAccessService.takeover(
params.id,
params.masterPassword,
params.email,
params.activeUserId,
),
).rejects.toThrow("Failed to decrypt grantor key");
expect(keyService.userPrivateKey$).toHaveBeenCalledWith(params.activeUserId);
expect(encryptService.decapsulateKeyUnsigned).toHaveBeenCalledWith(
new EncString(takeoverResponse.keyEncrypted),
userPrivateKey,
);
expect(keyService.makeMasterKey).not.toHaveBeenCalled();
expect(keyService.hashMasterKey).not.toHaveBeenCalled();
expect(keyService.encryptUserKeyWithMasterKey).not.toHaveBeenCalled();
expect(emergencyAccessApiService.postEmergencyAccessPassword).not.toHaveBeenCalled();
});
it("should not post a new password if decryption throws", async () => {
encryptService.decapsulateKeyUnsigned.mockReset();
encryptService.decapsulateKeyUnsigned.mockImplementationOnce(() => {
throw new Error("Failed to unwrap grantor key");
});
await expect(
emergencyAccessService.takeover(
params.id,
params.masterPassword,
params.email,
params.activeUserId,
),
).rejects.toThrowError("Failed to unwrap grantor key");
expect(keyService.userPrivateKey$).toHaveBeenCalledWith(params.activeUserId);
expect(encryptService.decapsulateKeyUnsigned).toHaveBeenCalledWith(
new EncString(takeoverResponse.keyEncrypted),
userPrivateKey,
);
expect(keyService.makeMasterKey).not.toHaveBeenCalled();
expect(keyService.hashMasterKey).not.toHaveBeenCalled();
expect(keyService.encryptUserKeyWithMasterKey).not.toHaveBeenCalled();
expect(emergencyAccessApiService.postEmergencyAccessPassword).not.toHaveBeenCalled(); expect(emergencyAccessApiService.postEmergencyAccessPassword).not.toHaveBeenCalled();
}); });
it("should throw an error if the users private key cannot be retrieved", async () => { it("should throw an error if the users private key cannot be retrieved", async () => {
emergencyAccessApiService.postEmergencyAccessTakeover.mockResolvedValueOnce({ keyService.userPrivateKey$.mockReturnValue(of(null));
keyEncrypted: "EncryptedKey",
kdf: KdfType.PBKDF2_SHA256,
kdfIterations: 500,
} as EmergencyAccessTakeoverResponse);
keyService.getPrivateKey.mockResolvedValue(null);
await expect(emergencyAccessService.takeover(mockId, mockEmail, mockName)).rejects.toThrow( await expect(
"user does not have a private key", emergencyAccessService.takeover(
); params.id,
params.masterPassword,
params.email,
params.activeUserId,
),
).rejects.toThrow("user does not have a private key");
expect(keyService.userPrivateKey$).toHaveBeenCalledWith(params.activeUserId);
expect(encryptService.decapsulateKeyUnsigned).not.toHaveBeenCalled();
expect(keyService.makeMasterKey).not.toHaveBeenCalled();
expect(keyService.hashMasterKey).not.toHaveBeenCalled();
expect(keyService.encryptUserKeyWithMasterKey).not.toHaveBeenCalled();
expect(emergencyAccessApiService.postEmergencyAccessPassword).not.toHaveBeenCalled(); expect(emergencyAccessApiService.postEmergencyAccessPassword).not.toHaveBeenCalled();
}); });
}); });

View File

@@ -1,4 +1,5 @@
import { Injectable } from "@angular/core"; import { Injectable } from "@angular/core";
import { firstValueFrom } from "rxjs";
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";
@@ -237,11 +238,14 @@ export class EmergencyAccessService
* Gets the grantor ciphers for an emergency access in view mode. * Gets the grantor ciphers for an emergency access in view mode.
* Intended for grantee. * Intended for grantee.
* @param id emergency access id * @param id emergency access id
* @param activeUserId the user id of the active user
*/ */
async getViewOnlyCiphers(id: string): Promise<CipherView[]> { async getViewOnlyCiphers(id: string, activeUserId: UserId): Promise<CipherView[]> {
const response = await this.emergencyAccessApiService.postEmergencyAccessView(id); const response = await this.emergencyAccessApiService.postEmergencyAccessView(id);
const activeUserPrivateKey = await this.keyService.getPrivateKey(); const activeUserPrivateKey = await firstValueFrom(
this.keyService.userPrivateKey$(activeUserId),
);
if (activeUserPrivateKey == null) { if (activeUserPrivateKey == null) {
throw new Error("Active user does not have a private key, cannot get view only ciphers."); throw new Error("Active user does not have a private key, cannot get view only ciphers.");
@@ -264,11 +268,14 @@ export class EmergencyAccessService
* @param id emergency access id * @param id emergency access id
* @param masterPassword new master password * @param masterPassword new master password
* @param email email address of grantee (must be consistent or login will fail) * @param email email address of grantee (must be consistent or login will fail)
* @param activeUserId the user id of the active user
*/ */
async takeover(id: string, masterPassword: string, email: string) { async takeover(id: string, masterPassword: string, email: string, activeUserId: UserId) {
const takeoverResponse = await this.emergencyAccessApiService.postEmergencyAccessTakeover(id); const takeoverResponse = await this.emergencyAccessApiService.postEmergencyAccessTakeover(id);
const activeUserPrivateKey = await this.keyService.getPrivateKey(); const activeUserPrivateKey = await firstValueFrom(
this.keyService.userPrivateKey$(activeUserId),
);
if (activeUserPrivateKey == null) { if (activeUserPrivateKey == null) {
throw new Error("Active user does not have a private key, cannot complete a takeover."); throw new Error("Active user does not have a private key, cannot complete a takeover.");
@@ -312,9 +319,7 @@ export class EmergencyAccessService
request.newMasterPasswordHash = masterKeyHash; request.newMasterPasswordHash = masterKeyHash;
request.key = encKey[1].encryptedString; request.key = encKey[1].encryptedString;
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. await this.emergencyAccessApiService.postEmergencyAccessPassword(id, request);
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.emergencyAccessApiService.postEmergencyAccessPassword(id, request);
} }
private async getEmergencyAccessData(): Promise<EmergencyAccessGranteeDetailsResponse[]> { private async getEmergencyAccessData(): Promise<EmergencyAccessGranteeDetailsResponse[]> {

View File

@@ -115,10 +115,12 @@ export class EmergencyAccessTakeoverDialogComponent implements OnInit {
this.parentSubmittingBehaviorSubject.next(true); this.parentSubmittingBehaviorSubject.next(true);
try { try {
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
await this.emergencyAccessService.takeover( await this.emergencyAccessService.takeover(
this.dialogData.emergencyAccessId, this.dialogData.emergencyAccessId,
passwordInputResult.newPassword, passwordInputResult.newPassword,
this.dialogData.grantorEmail, this.dialogData.grantorEmail,
activeUserId,
); );
} catch (e) { } catch (e) {
this.logService.error(e); this.logService.error(e);

View File

@@ -2,6 +2,8 @@ import { Component, OnInit } from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router"; import { ActivatedRoute, Router } from "@angular/router";
import { firstValueFrom } from "rxjs"; import { firstValueFrom } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { EmergencyAccessId } from "@bitwarden/common/types/guid"; import { EmergencyAccessId } from "@bitwarden/common/types/guid";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { DialogService } from "@bitwarden/components"; import { DialogService } from "@bitwarden/components";
@@ -27,6 +29,7 @@ export class EmergencyAccessViewComponent implements OnInit {
private route: ActivatedRoute, private route: ActivatedRoute,
private emergencyAccessService: EmergencyAccessService, private emergencyAccessService: EmergencyAccessService,
private dialogService: DialogService, private dialogService: DialogService,
private accountService: AccountService,
) {} ) {}
async ngOnInit() { async ngOnInit() {
@@ -37,7 +40,8 @@ export class EmergencyAccessViewComponent implements OnInit {
} }
this.id = qParams.id; this.id = qParams.id;
this.ciphers = await this.emergencyAccessService.getViewOnlyCiphers(qParams.id); const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
this.ciphers = await this.emergencyAccessService.getViewOnlyCiphers(qParams.id, userId);
this.loaded = true; this.loaded = true;
} }

View File

@@ -294,16 +294,6 @@ export abstract class KeyService {
* @param encPrivateKey An encrypted private key * @param encPrivateKey An encrypted private key
*/ */
abstract setPrivateKey(encPrivateKey: string, userId: UserId): Promise<void>; abstract setPrivateKey(encPrivateKey: string, userId: UserId): Promise<void>;
/**
* Returns the private key from memory. If not available, decrypts it
* from storage and stores it in memory
* @returns The user's private key
*
* @throws Error when no active user
*
* @deprecated Use {@link userPrivateKey$} instead.
*/
abstract getPrivateKey(): Promise<Uint8Array | null>;
/** /**
* Gets an observable stream of the given users decrypted private key, will emit null if the user * Gets an observable stream of the given users decrypted private key, will emit null if the user
@@ -311,6 +301,8 @@ export abstract class KeyService {
* encrypted private key at all. * encrypted private key at all.
* *
* @param userId The user id of the user to get the data for. * @param userId The user id of the user to get the data for.
* @returns An observable stream of the decrypted private key or null.
* @throws Error when decryption of the encrypted private key fails.
*/ */
abstract userPrivateKey$(userId: UserId): Observable<UserPrivateKey | null>; abstract userPrivateKey$(userId: UserId): Observable<UserPrivateKey | null>;

View File

@@ -494,77 +494,79 @@ describe("keyService", () => {
}); });
describe("userPrivateKey$", () => { describe("userPrivateKey$", () => {
type SetupKeysParams = { let mockUserKey: UserKey;
makeMasterKey: boolean; let mockUserPrivateKey: Uint8Array;
makeUserKey: boolean; let mockEncryptedPrivateKey: EncryptedString;
};
function setupKeys({ beforeEach(() => {
makeMasterKey, mockUserKey = makeSymmetricCryptoKey<UserKey>(64);
makeUserKey, mockEncryptedPrivateKey = makeEncString("encryptedPrivateKey").encryptedString!;
}: SetupKeysParams): [UserKey | null, MasterKey | null] { mockUserPrivateKey = makeStaticByteArray(10, 1);
const userKeyState = stateProvider.singleUser.getFake(mockUserId, USER_KEY); stateProvider.singleUser.getFake(mockUserId, USER_KEY).nextState(mockUserKey);
const fakeMasterKey = makeMasterKey ? makeSymmetricCryptoKey<MasterKey>(64) : null; stateProvider.singleUser
masterPasswordService.masterKeySubject.next(fakeMasterKey); .getFake(mockUserId, USER_ENCRYPTED_PRIVATE_KEY)
userKeyState.nextState(null); .nextState(mockEncryptedPrivateKey);
const fakeUserKey = makeUserKey ? makeSymmetricCryptoKey<UserKey>(64) : null; encryptService.unwrapDecapsulationKey.mockResolvedValue(mockUserPrivateKey);
userKeyState.nextState(fakeUserKey); });
return [fakeUserKey, fakeMasterKey];
}
it("will return users decrypted private key when user has a user key and encrypted private key set", async () => { it("returns the unwrapped user private key when user key and encrypted private key are set", async () => {
const [userKey] = setupKeys({ const result = await firstValueFrom(keyService.userPrivateKey$(mockUserId));
makeMasterKey: false,
makeUserKey: true, expect(result).toEqual(mockUserPrivateKey);
expect(encryptService.unwrapDecapsulationKey).toHaveBeenCalledWith(
new EncString(mockEncryptedPrivateKey),
mockUserKey,
);
});
it("throws an error if unwrapping encrypted private key fails", async () => {
encryptService.unwrapDecapsulationKey.mockImplementationOnce(() => {
throw new Error("Unwrapping failed");
}); });
const userEncryptedPrivateKeyState = stateProvider.singleUser.getFake( await expect(firstValueFrom(keyService.userPrivateKey$(mockUserId))).rejects.toThrow(
mockUserId, "Unwrapping failed",
USER_ENCRYPTED_PRIVATE_KEY,
); );
const fakeEncryptedUserPrivateKey = makeEncString("1");
userEncryptedPrivateKeyState.nextState(fakeEncryptedUserPrivateKey.encryptedString!);
// Decryption of the user private key
const fakeDecryptedUserPrivateKey = makeStaticByteArray(10, 1);
encryptService.unwrapDecapsulationKey.mockResolvedValue(fakeDecryptedUserPrivateKey);
const fakeUserPublicKey = makeStaticByteArray(10, 2);
cryptoFunctionService.rsaExtractPublicKey.mockResolvedValue(fakeUserPublicKey);
const userPrivateKey = await firstValueFrom(keyService.userPrivateKey$(mockUserId));
expect(encryptService.unwrapDecapsulationKey).toHaveBeenCalledWith(
fakeEncryptedUserPrivateKey,
userKey,
);
expect(userPrivateKey).toBe(fakeDecryptedUserPrivateKey);
}); });
it("returns null user private key when no user key is found", async () => { it("returns null if user key is not set", async () => {
setupKeys({ makeMasterKey: false, makeUserKey: false }); stateProvider.singleUser.getFake(mockUserId, USER_KEY).nextState(null);
const userPrivateKey = await firstValueFrom(keyService.userPrivateKey$(mockUserId)); const result = await firstValueFrom(keyService.userPrivateKey$(mockUserId));
expect(result).toBeNull();
expect(encryptService.unwrapDecapsulationKey).not.toHaveBeenCalled(); expect(encryptService.unwrapDecapsulationKey).not.toHaveBeenCalled();
expect(userPrivateKey).toBeFalsy();
}); });
it("returns null when user does not have a private key set", async () => { it("returns null if encrypted private key is not set", async () => {
setupKeys({ makeUserKey: true, makeMasterKey: false }); stateProvider.singleUser.getFake(mockUserId, USER_ENCRYPTED_PRIVATE_KEY).nextState(null);
const encryptedUserPrivateKeyState = stateProvider.singleUser.getFake( const result = await firstValueFrom(keyService.userPrivateKey$(mockUserId));
mockUserId,
USER_ENCRYPTED_PRIVATE_KEY,
);
encryptedUserPrivateKeyState.nextState(null);
const userPrivateKey = await firstValueFrom(keyService.userPrivateKey$(mockUserId)); expect(result).toBeNull();
expect(userPrivateKey).toBeFalsy(); expect(encryptService.unwrapDecapsulationKey).not.toHaveBeenCalled();
});
it("reacts to changes in user key or encrypted private key", async () => {
// Initial state: both set
let result = await firstValueFrom(keyService.userPrivateKey$(mockUserId));
expect(result).toEqual(mockUserPrivateKey);
// Change user key to null
stateProvider.singleUser.getFake(mockUserId, USER_KEY).nextState(null);
result = await firstValueFrom(keyService.userPrivateKey$(mockUserId));
expect(result).toBeNull();
// Restore user key, remove encrypted private key
stateProvider.singleUser.getFake(mockUserId, USER_KEY).nextState(mockUserKey);
stateProvider.singleUser.getFake(mockUserId, USER_ENCRYPTED_PRIVATE_KEY).nextState(null);
result = await firstValueFrom(keyService.userPrivateKey$(mockUserId));
expect(result).toBeNull();
}); });
}); });
@@ -1063,7 +1065,7 @@ describe("keyService", () => {
}); });
}); });
describe("userPrivateKey$", () => { describe("userEncryptionKeyPair$", () => {
type SetupKeysParams = { type SetupKeysParams = {
makeMasterKey: boolean; makeMasterKey: boolean;
makeUserKey: boolean; makeUserKey: boolean;

View File

@@ -501,16 +501,6 @@ export class DefaultKeyService implements KeyServiceAbstraction {
.update(() => encPrivateKey); .update(() => encPrivateKey);
} }
async getPrivateKey(): Promise<Uint8Array | null> {
const activeUserId = await firstValueFrom(this.stateProvider.activeUserId$);
if (activeUserId == null) {
throw new Error("User must be active while attempting to retrieve private key.");
}
return await firstValueFrom(this.userPrivateKey$(activeUserId));
}
// TODO: Make public key required // TODO: Make public key required
async getFingerprint(fingerprintMaterial: string, publicKey?: Uint8Array): Promise<string[]> { async getFingerprint(fingerprintMaterial: string, publicKey?: Uint8Array): Promise<string[]> {
if (publicKey == null) { if (publicKey == null) {