[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:
Matt Gibson 2024-01-29 16:53:01 -05:00 committed by GitHub
parent c199f02d44
commit 3a9dead640
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 485 additions and 118 deletions

View File

@ -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

View File

@ -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>[],

View File

@ -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
*/

View File

@ -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({

View File

@ -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),
),

View File

@ -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);
}
}

View File

@ -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,

View File

@ -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);

View File

@ -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({});
});
});

View File

@ -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;
},
});

View File

@ -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 (

View File

@ -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);
}

View File

@ -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());
});
});
});

View File

@ -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))]);
}
}

View File

@ -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">;

View File

@ -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">;