mirror of
https://github.com/bitwarden/browser.git
synced 2024-12-21 16:18:28 +01:00
[PM-5533] migrate provider keys (#7649)
* Provide RSA encryption in encrypt service * Define state for provider keys * Require cryptoService This is temporary until cryptoService has an observable active user private key. We don't want promise-based values in derive functions * Update crypto service provider keys to observables * Remove provider keys from state service * Migrate provider keys out of state account object * Correct Provider key state types * Prefix migration with current version number
This commit is contained in:
parent
c199f02d44
commit
3a9dead640
@ -4,7 +4,7 @@ import { ProfileOrganizationResponse } from "../../admin-console/models/response
|
||||
import { ProfileProviderOrganizationResponse } from "../../admin-console/models/response/profile-provider-organization.response";
|
||||
import { ProfileProviderResponse } from "../../admin-console/models/response/profile-provider.response";
|
||||
import { KdfConfig } from "../../auth/models/domain/kdf-config";
|
||||
import { OrganizationId } from "../../types/guid";
|
||||
import { OrganizationId, ProviderId } from "../../types/guid";
|
||||
import { UserKey, MasterKey, OrgKey, ProviderKey, PinKey, CipherKey } from "../../types/key";
|
||||
import { KeySuffixOptions, KdfType, HashPurpose } from "../enums";
|
||||
import { EncArrayBuffer } from "../models/domain/enc-array-buffer";
|
||||
@ -229,6 +229,7 @@ export abstract class CryptoService {
|
||||
* provider keys currently in memory
|
||||
* @param providers The providers to set keys for
|
||||
*/
|
||||
activeUserProviderKeys$: Observable<Record<ProviderId, ProviderKey>>;
|
||||
setProviderKeys: (orgs: ProfileProviderResponse[]) => Promise<void>;
|
||||
/**
|
||||
* @param providerId The desired provider
|
||||
@ -236,9 +237,9 @@ export abstract class CryptoService {
|
||||
*/
|
||||
getProviderKey: (providerId: string) => Promise<ProviderKey>;
|
||||
/**
|
||||
* @returns A map of the provider Ids to their symmetric keys
|
||||
* @returns A record of the provider Ids to their symmetric keys
|
||||
*/
|
||||
getProviderKeys: () => Promise<Map<string, ProviderKey>>;
|
||||
getProviderKeys: () => Promise<Record<ProviderId, ProviderKey>>;
|
||||
/**
|
||||
* @param memoryOnly Clear only the in-memory keys
|
||||
* @param userId The desired user
|
||||
|
@ -13,6 +13,8 @@ export abstract class EncryptService {
|
||||
) => Promise<EncArrayBuffer>;
|
||||
abstract decryptToUtf8: (encString: EncString, key: SymmetricCryptoKey) => Promise<string>;
|
||||
abstract decryptToBytes: (encThing: Encrypted, key: SymmetricCryptoKey) => Promise<Uint8Array>;
|
||||
abstract rsaEncrypt: (data: Uint8Array, publicKey: Uint8Array) => Promise<EncString>;
|
||||
abstract rsaDecrypt: (data: EncString, privateKey: Uint8Array) => Promise<Uint8Array>;
|
||||
abstract resolveLegacyKey: (key: SymmetricCryptoKey, encThing: Encrypted) => SymmetricCryptoKey;
|
||||
abstract decryptItems: <T extends InitializerMetadata>(
|
||||
items: Decryptable<T>[],
|
||||
|
@ -216,11 +216,6 @@ export abstract class StateService<T extends Account = Account> {
|
||||
setDecryptedPolicies: (value: Policy[], options?: StorageOptions) => Promise<void>;
|
||||
getDecryptedPrivateKey: (options?: StorageOptions) => Promise<Uint8Array>;
|
||||
setDecryptedPrivateKey: (value: Uint8Array, options?: StorageOptions) => Promise<void>;
|
||||
getDecryptedProviderKeys: (options?: StorageOptions) => Promise<Map<string, SymmetricCryptoKey>>;
|
||||
setDecryptedProviderKeys: (
|
||||
value: Map<string, SymmetricCryptoKey>,
|
||||
options?: StorageOptions,
|
||||
) => Promise<void>;
|
||||
/**
|
||||
* @deprecated Do not call this directly, use SendService
|
||||
*/
|
||||
@ -363,8 +358,6 @@ export abstract class StateService<T extends Account = Account> {
|
||||
) => Promise<void>;
|
||||
getEncryptedPrivateKey: (options?: StorageOptions) => Promise<string>;
|
||||
setEncryptedPrivateKey: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
getEncryptedProviderKeys: (options?: StorageOptions) => Promise<any>;
|
||||
setEncryptedProviderKeys: (value: any, options?: StorageOptions) => Promise<void>;
|
||||
/**
|
||||
* @deprecated Do not call this directly, use SendService
|
||||
*/
|
||||
|
@ -57,18 +57,6 @@ describe("AccountKeys", () => {
|
||||
expect(spy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should deserialize organizationKeys", () => {
|
||||
const spy = jest.spyOn(SymmetricCryptoKey, "fromJSON");
|
||||
AccountKeys.fromJSON({ organizationKeys: [{ orgId: "keyJSON" }] } as any);
|
||||
expect(spy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should deserialize providerKeys", () => {
|
||||
const spy = jest.spyOn(SymmetricCryptoKey, "fromJSON");
|
||||
AccountKeys.fromJSON({ providerKeys: [{ providerId: "keyJSON" }] } as any);
|
||||
expect(spy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should deserialize privateKey", () => {
|
||||
const spy = jest.spyOn(EncryptionPair, "fromJSON");
|
||||
AccountKeys.fromJSON({
|
||||
|
@ -125,10 +125,6 @@ export class AccountKeys {
|
||||
masterKey?: MasterKey;
|
||||
masterKeyEncryptedUserKey?: string;
|
||||
deviceKey?: ReturnType<SymmetricCryptoKey["toJSON"]>;
|
||||
providerKeys?: EncryptionPair<any, Record<string, SymmetricCryptoKey>> = new EncryptionPair<
|
||||
any,
|
||||
Record<string, SymmetricCryptoKey>
|
||||
>();
|
||||
privateKey?: EncryptionPair<string, Uint8Array> = new EncryptionPair<string, Uint8Array>();
|
||||
publicKey?: Uint8Array;
|
||||
apiKeyClientSecret?: string;
|
||||
@ -167,7 +163,6 @@ export class AccountKeys {
|
||||
obj?.cryptoSymmetricKey,
|
||||
SymmetricCryptoKey.fromJSON,
|
||||
),
|
||||
providerKeys: AccountKeys.initRecordEncryptionPairsFromJSON(obj?.providerKeys),
|
||||
privateKey: EncryptionPair.fromJSON<string, Uint8Array>(obj?.privateKey, (decObj: string) =>
|
||||
Utils.fromByteStringToArray(decObj),
|
||||
),
|
||||
|
@ -8,8 +8,8 @@ import { ProfileProviderResponse } from "../../admin-console/models/response/pro
|
||||
import { AccountService } from "../../auth/abstractions/account.service";
|
||||
import { KdfConfig } from "../../auth/models/domain/kdf-config";
|
||||
import { Utils } from "../../platform/misc/utils";
|
||||
import { OrganizationId, UserId } from "../../types/guid";
|
||||
import { UserKey, MasterKey, OrgKey, ProviderKey, PinKey, CipherKey } from "../../types/key";
|
||||
import { OrganizationId, ProviderId, UserId } from "../../types/guid";
|
||||
import { OrgKey, UserKey, MasterKey, ProviderKey, PinKey, CipherKey } from "../../types/key";
|
||||
import { CryptoFunctionService } from "../abstractions/crypto-function.service";
|
||||
import { CryptoService as CryptoServiceAbstraction } from "../abstractions/crypto.service";
|
||||
import { EncryptService } from "../abstractions/encrypt.service";
|
||||
@ -29,7 +29,7 @@ import {
|
||||
import { sequentialize } from "../misc/sequentialize";
|
||||
import { EFFLongWordList } from "../misc/wordlist";
|
||||
import { EncArrayBuffer } from "../models/domain/enc-array-buffer";
|
||||
import { EncString } from "../models/domain/enc-string";
|
||||
import { EncString, EncryptedString } from "../models/domain/enc-string";
|
||||
import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key";
|
||||
import { ActiveUserState, DerivedState, StateProvider } from "../state";
|
||||
|
||||
@ -37,6 +37,7 @@ import {
|
||||
USER_ENCRYPTED_ORGANIZATION_KEYS,
|
||||
USER_ORGANIZATION_KEYS,
|
||||
} from "./key-state/org-keys.state";
|
||||
import { USER_ENCRYPTED_PROVIDER_KEYS, USER_PROVIDER_KEYS } from "./key-state/provider-keys.state";
|
||||
import { USER_EVER_HAD_USER_KEY } from "./key-state/user-key.state";
|
||||
|
||||
export class CryptoService implements CryptoServiceAbstraction {
|
||||
@ -45,8 +46,13 @@ export class CryptoService implements CryptoServiceAbstraction {
|
||||
Record<OrganizationId, EncryptedOrganizationKeyData>
|
||||
>;
|
||||
private readonly activeUserOrgKeysState: DerivedState<Record<OrganizationId, OrgKey>>;
|
||||
private readonly activeUserEncryptedProviderKeysState: ActiveUserState<
|
||||
Record<ProviderId, EncryptedString>
|
||||
>;
|
||||
private readonly activeUserProviderKeysState: DerivedState<Record<OrganizationId, ProviderKey>>;
|
||||
|
||||
readonly activeUserOrgKeys$: Observable<Record<OrganizationId, OrgKey>>;
|
||||
readonly activeUserProviderKeys$: Observable<Record<ProviderId, ProviderKey>>;
|
||||
|
||||
readonly everHadUserKey$;
|
||||
|
||||
@ -68,9 +74,18 @@ export class CryptoService implements CryptoServiceAbstraction {
|
||||
USER_ORGANIZATION_KEYS,
|
||||
{ cryptoService: this },
|
||||
);
|
||||
this.activeUserEncryptedProviderKeysState = stateProvider.getActive(
|
||||
USER_ENCRYPTED_PROVIDER_KEYS,
|
||||
);
|
||||
this.activeUserProviderKeysState = stateProvider.getDerived(
|
||||
this.activeUserEncryptedProviderKeysState.state$,
|
||||
USER_PROVIDER_KEYS,
|
||||
{ encryptService: this.encryptService, cryptoService: this },
|
||||
);
|
||||
|
||||
this.everHadUserKey$ = this.activeUserEverHadUserKey.state$.pipe(map((x) => x ?? false));
|
||||
this.activeUserOrgKeys$ = this.activeUserOrgKeysState.state$; // null handled by `derive` function
|
||||
this.activeUserProviderKeys$ = this.activeUserProviderKeysState.state$; // null handled by `derive` function
|
||||
}
|
||||
|
||||
async setUserKey(key: UserKey, userId?: UserId): Promise<void> {
|
||||
@ -396,65 +411,44 @@ export class CryptoService implements CryptoServiceAbstraction {
|
||||
}
|
||||
|
||||
async setProviderKeys(providers: ProfileProviderResponse[]): Promise<void> {
|
||||
const providerKeys: any = {};
|
||||
providers.forEach((provider) => {
|
||||
providerKeys[provider.id] = provider.key;
|
||||
});
|
||||
this.activeUserEncryptedProviderKeysState.update((_) => {
|
||||
const encProviderKeys: { [providerId: ProviderId]: EncryptedString } = {};
|
||||
|
||||
await this.stateService.setDecryptedProviderKeys(null);
|
||||
return await this.stateService.setEncryptedProviderKeys(providerKeys);
|
||||
providers.forEach((provider) => {
|
||||
encProviderKeys[provider.id as ProviderId] = provider.key as EncryptedString;
|
||||
});
|
||||
|
||||
return encProviderKeys;
|
||||
});
|
||||
}
|
||||
|
||||
async getProviderKey(providerId: string): Promise<ProviderKey> {
|
||||
async getProviderKey(providerId: ProviderId): Promise<ProviderKey> {
|
||||
if (providerId == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const providerKeys = await this.getProviderKeys();
|
||||
if (providerKeys == null || !providerKeys.has(providerId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return providerKeys.get(providerId);
|
||||
return (await firstValueFrom(this.activeUserProviderKeys$))[providerId] ?? null;
|
||||
}
|
||||
|
||||
@sequentialize(() => "getProviderKeys")
|
||||
async getProviderKeys(): Promise<Map<string, ProviderKey>> {
|
||||
const providerKeys: Map<string, ProviderKey> = new Map<string, ProviderKey>();
|
||||
const decryptedProviderKeys = await this.stateService.getDecryptedProviderKeys();
|
||||
if (decryptedProviderKeys != null && decryptedProviderKeys.size > 0) {
|
||||
return decryptedProviderKeys as Map<string, ProviderKey>;
|
||||
}
|
||||
|
||||
const encProviderKeys = await this.stateService.getEncryptedProviderKeys();
|
||||
if (encProviderKeys == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let setKey = false;
|
||||
|
||||
for (const orgId in encProviderKeys) {
|
||||
// eslint-disable-next-line
|
||||
if (!encProviderKeys.hasOwnProperty(orgId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const decValue = await this.rsaDecrypt(encProviderKeys[orgId]);
|
||||
providerKeys.set(orgId, new SymmetricCryptoKey(decValue) as ProviderKey);
|
||||
setKey = true;
|
||||
}
|
||||
|
||||
if (setKey) {
|
||||
await this.stateService.setDecryptedProviderKeys(providerKeys);
|
||||
}
|
||||
|
||||
return providerKeys;
|
||||
async getProviderKeys(): Promise<Record<ProviderId, ProviderKey>> {
|
||||
return await firstValueFrom(this.activeUserProviderKeys$);
|
||||
}
|
||||
|
||||
async clearProviderKeys(memoryOnly?: boolean, userId?: UserId): Promise<void> {
|
||||
await this.stateService.setDecryptedProviderKeys(null, { userId: userId });
|
||||
if (!memoryOnly) {
|
||||
await this.stateService.setEncryptedProviderKeys(null, { userId: userId });
|
||||
const activeUserId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
|
||||
const userIdIsActive = userId == null || userId === activeUserId;
|
||||
if (memoryOnly && userIdIsActive) {
|
||||
// provider keys are only cached for active users
|
||||
await this.activeUserProviderKeysState.forceValue({});
|
||||
} else {
|
||||
if (userId == null && activeUserId == null) {
|
||||
// nothing to do
|
||||
return;
|
||||
}
|
||||
await this.stateProvider
|
||||
.getUser(userId ?? activeUserId, USER_ENCRYPTED_PROVIDER_KEYS)
|
||||
.update(() => null);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -147,6 +147,44 @@ export class EncryptServiceImplementation implements EncryptService {
|
||||
return result ?? null;
|
||||
}
|
||||
|
||||
async rsaEncrypt(data: Uint8Array, publicKey: Uint8Array): Promise<EncString> {
|
||||
if (data == null) {
|
||||
throw new Error("No data provided for encryption.");
|
||||
}
|
||||
|
||||
if (publicKey == null) {
|
||||
throw new Error("No public key provided for encryption.");
|
||||
}
|
||||
const encrypted = await this.cryptoFunctionService.rsaEncrypt(data, publicKey, "sha1");
|
||||
return new EncString(EncryptionType.Rsa2048_OaepSha1_B64, Utils.fromBufferToB64(encrypted));
|
||||
}
|
||||
|
||||
async rsaDecrypt(data: EncString, privateKey: Uint8Array): Promise<Uint8Array> {
|
||||
if (data == null) {
|
||||
throw new Error("No data provided for decryption.");
|
||||
}
|
||||
|
||||
let algorithm: "sha1" | "sha256";
|
||||
switch (data.encryptionType) {
|
||||
case EncryptionType.Rsa2048_OaepSha1_B64:
|
||||
case EncryptionType.Rsa2048_OaepSha1_HmacSha256_B64:
|
||||
algorithm = "sha1";
|
||||
break;
|
||||
case EncryptionType.Rsa2048_OaepSha256_B64:
|
||||
case EncryptionType.Rsa2048_OaepSha256_HmacSha256_B64:
|
||||
algorithm = "sha256";
|
||||
break;
|
||||
default:
|
||||
throw new Error("Invalid encryption type.");
|
||||
}
|
||||
|
||||
if (privateKey == null) {
|
||||
throw new Error("No private key provided for decryption.");
|
||||
}
|
||||
|
||||
return this.cryptoFunctionService.rsaDecrypt(data.dataBytes, privateKey, algorithm);
|
||||
}
|
||||
|
||||
async decryptItems<T extends InitializerMetadata>(
|
||||
items: Decryptable<T>[],
|
||||
key: SymmetricCryptoKey,
|
||||
|
@ -5,6 +5,7 @@ import { CsprngArray } from "../../types/csprng";
|
||||
import { CryptoFunctionService } from "../abstractions/crypto-function.service";
|
||||
import { LogService } from "../abstractions/log.service";
|
||||
import { EncryptionType } from "../enums";
|
||||
import { Utils } from "../misc/utils";
|
||||
import { EncArrayBuffer } from "../models/domain/enc-array-buffer";
|
||||
import { EncString } from "../models/domain/enc-string";
|
||||
import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key";
|
||||
@ -163,6 +164,79 @@ describe("EncryptService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("rsa", () => {
|
||||
const data = makeStaticByteArray(10, 100);
|
||||
const encryptedData = makeStaticByteArray(10, 150);
|
||||
const publicKey = makeStaticByteArray(10, 200);
|
||||
const privateKey = makeStaticByteArray(10, 250);
|
||||
const encString = makeEncString(encryptedData);
|
||||
|
||||
function makeEncString(data: Uint8Array): EncString {
|
||||
return new EncString(EncryptionType.Rsa2048_OaepSha1_B64, Utils.fromBufferToB64(data));
|
||||
}
|
||||
|
||||
describe("rsaEncrypt", () => {
|
||||
it("throws if no data is provided", () => {
|
||||
return expect(encryptService.rsaEncrypt(null, publicKey)).rejects.toThrow("No data");
|
||||
});
|
||||
|
||||
it("throws if no public key is provided", () => {
|
||||
return expect(encryptService.rsaEncrypt(data, null)).rejects.toThrow("No public key");
|
||||
});
|
||||
|
||||
it("encrypts data with provided key", async () => {
|
||||
cryptoFunctionService.rsaEncrypt.mockResolvedValue(encryptedData);
|
||||
|
||||
const actual = await encryptService.rsaEncrypt(data, publicKey);
|
||||
|
||||
expect(cryptoFunctionService.rsaEncrypt).toBeCalledWith(
|
||||
expect.toEqualBuffer(data),
|
||||
expect.toEqualBuffer(publicKey),
|
||||
"sha1",
|
||||
);
|
||||
|
||||
expect(actual).toEqual(encString);
|
||||
expect(actual.dataBytes).toEqualBuffer(encryptedData);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rsaDecrypt", () => {
|
||||
it("throws if no data is provided", () => {
|
||||
return expect(encryptService.rsaDecrypt(null, privateKey)).rejects.toThrow("No data");
|
||||
});
|
||||
|
||||
it("throws if no private key is provided", () => {
|
||||
return expect(encryptService.rsaDecrypt(encString, null)).rejects.toThrow("No private key");
|
||||
});
|
||||
|
||||
it.each([
|
||||
EncryptionType.AesCbc256_B64,
|
||||
EncryptionType.AesCbc128_HmacSha256_B64,
|
||||
EncryptionType.AesCbc256_HmacSha256_B64,
|
||||
])("throws if encryption type is %s", async (encType) => {
|
||||
encString.encryptionType = encType;
|
||||
|
||||
await expect(encryptService.rsaDecrypt(encString, privateKey)).rejects.toThrow(
|
||||
"Invalid encryption type",
|
||||
);
|
||||
});
|
||||
|
||||
it("decrypts data with provided key", async () => {
|
||||
cryptoFunctionService.rsaDecrypt.mockResolvedValue(data);
|
||||
|
||||
const actual = await encryptService.rsaDecrypt(makeEncString(data), privateKey);
|
||||
|
||||
expect(cryptoFunctionService.rsaDecrypt).toBeCalledWith(
|
||||
expect.toEqualBuffer(data),
|
||||
expect.toEqualBuffer(privateKey),
|
||||
"sha1",
|
||||
);
|
||||
|
||||
expect(actual).toEqualBuffer(data);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveLegacyKey", () => {
|
||||
it("creates a legacy key if required", async () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(32), EncryptionType.AesCbc256_B64);
|
||||
|
@ -0,0 +1,83 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { makeStaticByteArray } from "../../../../spec";
|
||||
import { ProviderId } from "../../../types/guid";
|
||||
import { ProviderKey, UserPrivateKey } from "../../../types/key";
|
||||
import { EncryptService } from "../../abstractions/encrypt.service";
|
||||
import { EncryptionType } from "../../enums";
|
||||
import { Utils } from "../../misc/utils";
|
||||
import { EncString, EncryptedString } from "../../models/domain/enc-string";
|
||||
import { SymmetricCryptoKey } from "../../models/domain/symmetric-crypto-key";
|
||||
import { CryptoService } from "../crypto.service";
|
||||
|
||||
import { USER_ENCRYPTED_PROVIDER_KEYS, USER_PROVIDER_KEYS } from "./provider-keys.state";
|
||||
|
||||
function makeEncString(data?: string) {
|
||||
data ??= Utils.newGuid();
|
||||
return new EncString(EncryptionType.AesCbc256_HmacSha256_B64, data, "test", "test");
|
||||
}
|
||||
|
||||
describe("encrypted provider keys", () => {
|
||||
const sut = USER_ENCRYPTED_PROVIDER_KEYS;
|
||||
|
||||
it("should deserialize encrypted provider keys", () => {
|
||||
const encryptedProviderKeys = {
|
||||
"provider-id-1": makeEncString().encryptedString,
|
||||
"provider-id-2": makeEncString().encryptedString,
|
||||
};
|
||||
|
||||
const result = sut.deserializer(JSON.parse(JSON.stringify(encryptedProviderKeys)));
|
||||
|
||||
expect(result).toEqual(encryptedProviderKeys);
|
||||
});
|
||||
});
|
||||
|
||||
describe("derived decrypted provider keys", () => {
|
||||
const encryptService = mock<EncryptService>();
|
||||
const cryptoService = mock<CryptoService>();
|
||||
const userPrivateKey = makeStaticByteArray(64, 0) as UserPrivateKey;
|
||||
const sut = USER_PROVIDER_KEYS;
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it("should deserialize provider keys", async () => {
|
||||
const decryptedProviderKeys = {
|
||||
"provider-id-1": new SymmetricCryptoKey(makeStaticByteArray(64, 1)) as ProviderKey,
|
||||
"provider-id-2": new SymmetricCryptoKey(makeStaticByteArray(64, 2)) as ProviderKey,
|
||||
};
|
||||
|
||||
const result = sut.deserialize(JSON.parse(JSON.stringify(decryptedProviderKeys)));
|
||||
|
||||
expect(result).toEqual(decryptedProviderKeys);
|
||||
});
|
||||
|
||||
it("should derive provider keys", async () => {
|
||||
const encryptedProviderKeys = {
|
||||
"provider-id-1": makeEncString().encryptedString,
|
||||
"provider-id-2": makeEncString().encryptedString,
|
||||
};
|
||||
|
||||
const decryptedProviderKeys = {
|
||||
"provider-id-1": new SymmetricCryptoKey(makeStaticByteArray(64, 1)) as ProviderKey,
|
||||
"provider-id-2": new SymmetricCryptoKey(makeStaticByteArray(64, 2)) as ProviderKey,
|
||||
};
|
||||
|
||||
encryptService.rsaDecrypt.mockResolvedValueOnce(decryptedProviderKeys["provider-id-1"].key);
|
||||
encryptService.rsaDecrypt.mockResolvedValueOnce(decryptedProviderKeys["provider-id-2"].key);
|
||||
cryptoService.getPrivateKey.mockResolvedValueOnce(userPrivateKey);
|
||||
|
||||
const result = await sut.derive(encryptedProviderKeys, { encryptService, cryptoService });
|
||||
|
||||
expect(result).toEqual(decryptedProviderKeys);
|
||||
});
|
||||
|
||||
it("should handle null input values", async () => {
|
||||
const encryptedProviderKeys: Record<ProviderId, EncryptedString> = null;
|
||||
|
||||
const result = await sut.derive(encryptedProviderKeys, { encryptService, cryptoService });
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
});
|
@ -0,0 +1,45 @@
|
||||
import { ProviderId } from "../../../types/guid";
|
||||
import { ProviderKey } from "../../../types/key";
|
||||
import { EncryptService } from "../../abstractions/encrypt.service";
|
||||
import { EncString, EncryptedString } from "../../models/domain/enc-string";
|
||||
import { SymmetricCryptoKey } from "../../models/domain/symmetric-crypto-key";
|
||||
import { KeyDefinition, CRYPTO_DISK, DeriveDefinition } from "../../state";
|
||||
import { CryptoService } from "../crypto.service";
|
||||
|
||||
export const USER_ENCRYPTED_PROVIDER_KEYS = KeyDefinition.record<EncryptedString, ProviderId>(
|
||||
CRYPTO_DISK,
|
||||
"providerKeys",
|
||||
{
|
||||
deserializer: (obj) => obj,
|
||||
},
|
||||
);
|
||||
|
||||
export const USER_PROVIDER_KEYS = DeriveDefinition.from<
|
||||
Record<ProviderId, EncryptedString>,
|
||||
Record<ProviderId, ProviderKey>,
|
||||
{ encryptService: EncryptService; cryptoService: CryptoService } // TODO: This should depend on an active user private key observable directly
|
||||
>(USER_ENCRYPTED_PROVIDER_KEYS, {
|
||||
deserializer: (obj) => {
|
||||
const result: Record<ProviderId, ProviderKey> = {};
|
||||
for (const providerId of Object.keys(obj ?? {}) as ProviderId[]) {
|
||||
result[providerId] = SymmetricCryptoKey.fromJSON(obj[providerId]) as ProviderKey;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
derive: async (from, { encryptService, cryptoService }) => {
|
||||
const result: Record<ProviderId, ProviderKey> = {};
|
||||
for (const providerId of Object.keys(from ?? {}) as ProviderId[]) {
|
||||
if (result[providerId] != null) {
|
||||
continue;
|
||||
}
|
||||
const encrypted = new EncString(from[providerId]);
|
||||
const privateKey = await cryptoService.getPrivateKey();
|
||||
const decrypted = await encryptService.rsaDecrypt(encrypted, privateKey);
|
||||
const providerKey = new SymmetricCryptoKey(decrypted) as ProviderKey;
|
||||
|
||||
result[providerId] = providerKey;
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
});
|
@ -1072,29 +1072,6 @@ export class StateService<
|
||||
);
|
||||
}
|
||||
|
||||
async getDecryptedProviderKeys(
|
||||
options?: StorageOptions,
|
||||
): Promise<Map<string, SymmetricCryptoKey>> {
|
||||
const account = await this.getAccount(
|
||||
this.reconcileOptions(options, await this.defaultInMemoryOptions()),
|
||||
);
|
||||
return Utils.recordToMap(account?.keys?.providerKeys?.decrypted);
|
||||
}
|
||||
|
||||
async setDecryptedProviderKeys(
|
||||
value: Map<string, SymmetricCryptoKey>,
|
||||
options?: StorageOptions,
|
||||
): Promise<void> {
|
||||
const account = await this.getAccount(
|
||||
this.reconcileOptions(options, await this.defaultInMemoryOptions()),
|
||||
);
|
||||
account.keys.providerKeys.decrypted = Utils.mapToRecord(value);
|
||||
await this.saveAccount(
|
||||
account,
|
||||
this.reconcileOptions(options, await this.defaultInMemoryOptions()),
|
||||
);
|
||||
}
|
||||
|
||||
@withPrototypeForArrayMembers(SendView)
|
||||
async getDecryptedSends(options?: StorageOptions): Promise<SendView[]> {
|
||||
return (
|
||||
@ -1912,23 +1889,6 @@ export class StateService<
|
||||
);
|
||||
}
|
||||
|
||||
async getEncryptedProviderKeys(options?: StorageOptions): Promise<any> {
|
||||
return (
|
||||
await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions()))
|
||||
)?.keys?.providerKeys?.encrypted;
|
||||
}
|
||||
|
||||
async setEncryptedProviderKeys(value: any, options?: StorageOptions): Promise<void> {
|
||||
const account = await this.getAccount(
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
||||
);
|
||||
account.keys.providerKeys.encrypted = value;
|
||||
await this.saveAccount(
|
||||
account,
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
||||
);
|
||||
}
|
||||
|
||||
@withPrototypeForObjectValues(SendData)
|
||||
async getEncryptedSends(options?: StorageOptions): Promise<{ [id: string]: SendData }> {
|
||||
return (
|
||||
|
@ -8,6 +8,7 @@ import { MigrationHelper } from "./migration-helper";
|
||||
import { EverHadUserKeyMigrator } from "./migrations/10-move-ever-had-user-key-to-state-providers";
|
||||
import { OrganizationKeyMigrator } from "./migrations/11-move-org-keys-to-state-providers";
|
||||
import { MoveEnvironmentStateToProviders } from "./migrations/12-move-environment-state-to-providers";
|
||||
import { ProviderKeyMigrator } from "./migrations/13-move-provider-keys-to-state-providers";
|
||||
import { FixPremiumMigrator } from "./migrations/3-fix-premium";
|
||||
import { RemoveEverBeenUnlockedMigrator } from "./migrations/4-remove-ever-been-unlocked";
|
||||
import { AddKeyTypeToOrgKeysMigrator } from "./migrations/5-add-key-type-to-org-keys";
|
||||
@ -18,7 +19,7 @@ import { MoveBrowserSettingsToGlobal } from "./migrations/9-move-browser-setting
|
||||
import { MinVersionMigrator } from "./migrations/min-version";
|
||||
|
||||
export const MIN_VERSION = 2;
|
||||
export const CURRENT_VERSION = 12;
|
||||
export const CURRENT_VERSION = 13;
|
||||
export type MinVersion = typeof MIN_VERSION;
|
||||
|
||||
export async function migrate(
|
||||
@ -46,7 +47,8 @@ export async function migrate(
|
||||
.with(MoveBrowserSettingsToGlobal, 8, 9)
|
||||
.with(EverHadUserKeyMigrator, 9, 10)
|
||||
.with(OrganizationKeyMigrator, 10, 11)
|
||||
.with(MoveEnvironmentStateToProviders, 11, CURRENT_VERSION)
|
||||
.with(MoveEnvironmentStateToProviders, 11, 12)
|
||||
.with(ProviderKeyMigrator, 12, CURRENT_VERSION)
|
||||
|
||||
.migrate(migrationHelper);
|
||||
}
|
||||
|
@ -0,0 +1,135 @@
|
||||
import { MockProxy, any } from "jest-mock-extended";
|
||||
|
||||
import { MigrationHelper } from "../migration-helper";
|
||||
import { mockMigrationHelper } from "../migration-helper.spec";
|
||||
|
||||
import { ProviderKeyMigrator } from "./13-move-provider-keys-to-state-providers";
|
||||
|
||||
function exampleJSON() {
|
||||
return {
|
||||
global: {
|
||||
otherStuff: "otherStuff1",
|
||||
},
|
||||
authenticatedAccounts: ["user-1", "user-2", "user-3"],
|
||||
"user-1": {
|
||||
keys: {
|
||||
providerKeys: {
|
||||
encrypted: {
|
||||
"provider-id-1": "provider-key-1",
|
||||
"provider-id-2": "provider-key-2",
|
||||
},
|
||||
},
|
||||
otherStuff: "overStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
},
|
||||
"user-2": {
|
||||
keys: {
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function rollbackJSON() {
|
||||
return {
|
||||
"user_user-1_crypto_providerKeys": {
|
||||
"provider-id-1": "provider-key-1",
|
||||
"provider-id-2": "provider-key-2",
|
||||
},
|
||||
"user_user-2_crypto_providerKeys": null as any,
|
||||
global: {
|
||||
otherStuff: "otherStuff1",
|
||||
},
|
||||
authenticatedAccounts: ["user-1", "user-2", "user-3"],
|
||||
"user-1": {
|
||||
keys: {
|
||||
otherStuff: "overStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
},
|
||||
"user-2": {
|
||||
keys: {
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("ProviderKeysMigrator", () => {
|
||||
let helper: MockProxy<MigrationHelper>;
|
||||
let sut: ProviderKeyMigrator;
|
||||
const keyDefinitionLike = {
|
||||
key: "providerKeys",
|
||||
stateDefinition: {
|
||||
name: "crypto",
|
||||
},
|
||||
};
|
||||
|
||||
describe("migrate", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(exampleJSON(), 12);
|
||||
sut = new ProviderKeyMigrator(12, 13);
|
||||
});
|
||||
|
||||
it("should remove providerKeys from all accounts", async () => {
|
||||
await sut.migrate(helper);
|
||||
expect(helper.set).toHaveBeenCalledTimes(1);
|
||||
expect(helper.set).toHaveBeenCalledWith("user-1", {
|
||||
keys: {
|
||||
otherStuff: "overStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
});
|
||||
});
|
||||
|
||||
it("should set providerKeys value for each account", async () => {
|
||||
await sut.migrate(helper);
|
||||
|
||||
expect(helper.setToUser).toHaveBeenCalledTimes(1);
|
||||
expect(helper.setToUser).toHaveBeenCalledWith("user-1", keyDefinitionLike, {
|
||||
"provider-id-1": "provider-key-1",
|
||||
"provider-id-2": "provider-key-2",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("rollback", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(rollbackJSON(), 11);
|
||||
sut = new ProviderKeyMigrator(12, 13);
|
||||
});
|
||||
|
||||
it.each(["user-1", "user-2", "user-3"])("should null out new values %s", async (userId) => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.setToUser).toHaveBeenCalledWith(userId, keyDefinitionLike, null);
|
||||
});
|
||||
|
||||
it("should add explicit value back to accounts", async () => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.set).toHaveBeenCalledTimes(1);
|
||||
expect(helper.set).toHaveBeenCalledWith("user-1", {
|
||||
keys: {
|
||||
providerKeys: {
|
||||
encrypted: {
|
||||
"provider-id-1": "provider-key-1",
|
||||
"provider-id-2": "provider-key-2",
|
||||
},
|
||||
},
|
||||
otherStuff: "overStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
});
|
||||
});
|
||||
|
||||
it("should not try to restore values to missing accounts", async () => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.set).not.toHaveBeenCalledWith("user-3", any());
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,53 @@
|
||||
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
|
||||
import { Migrator } from "../migrator";
|
||||
|
||||
type ExpectedAccountType = {
|
||||
keys?: {
|
||||
providerKeys?: {
|
||||
encrypted?: Record<string, string>; // Record<ProviderId, EncryptedString> where EncryptedString is the ProviderKey encrypted by the UserKey.
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const USER_ENCRYPTED_PROVIDER_KEYS: KeyDefinitionLike = {
|
||||
key: "providerKeys",
|
||||
stateDefinition: {
|
||||
name: "crypto",
|
||||
},
|
||||
};
|
||||
|
||||
export class ProviderKeyMigrator extends Migrator<12, 13> {
|
||||
async migrate(helper: MigrationHelper): Promise<void> {
|
||||
const accounts = await helper.getAccounts<ExpectedAccountType>();
|
||||
async function migrateAccount(userId: string, account: ExpectedAccountType): Promise<void> {
|
||||
const value = account?.keys?.providerKeys?.encrypted;
|
||||
if (value != null) {
|
||||
await helper.setToUser(userId, USER_ENCRYPTED_PROVIDER_KEYS, value);
|
||||
delete account.keys.providerKeys;
|
||||
await helper.set(userId, account);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]);
|
||||
}
|
||||
async rollback(helper: MigrationHelper): Promise<void> {
|
||||
const accounts = await helper.getAccounts<ExpectedAccountType>();
|
||||
async function rollbackAccount(userId: string, account: ExpectedAccountType): Promise<void> {
|
||||
const value = await helper.getFromUser<Record<string, string>>(
|
||||
userId,
|
||||
USER_ENCRYPTED_PROVIDER_KEYS,
|
||||
);
|
||||
if (account && value) {
|
||||
account.keys = Object.assign(account.keys ?? {}, {
|
||||
providerKeys: {
|
||||
encrypted: value,
|
||||
},
|
||||
});
|
||||
await helper.set(userId, account);
|
||||
}
|
||||
await helper.setToUser(userId, USER_ENCRYPTED_PROVIDER_KEYS, null);
|
||||
}
|
||||
|
||||
await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]);
|
||||
}
|
||||
}
|
@ -4,3 +4,4 @@ export type Guid = Opaque<string, "Guid">;
|
||||
|
||||
export type UserId = Opaque<string, "UserId">;
|
||||
export type OrganizationId = Opaque<string, "OrganizationId">;
|
||||
export type ProviderId = Opaque<string, "ProviderId">;
|
||||
|
@ -11,3 +11,6 @@ export type PinKey = Opaque<SymmetricCryptoKey, "PinKey">;
|
||||
export type OrgKey = Opaque<SymmetricCryptoKey, "OrgKey">;
|
||||
export type ProviderKey = Opaque<SymmetricCryptoKey, "ProviderKey">;
|
||||
export type CipherKey = Opaque<SymmetricCryptoKey, "CipherKey">;
|
||||
|
||||
// asymmetric keys
|
||||
export type UserPrivateKey = Opaque<Uint8Array, "UserPrivateKey">;
|
||||
|
Loading…
Reference in New Issue
Block a user