mirror of
https://github.com/bitwarden/browser.git
synced 2024-11-22 11:45:59 +01:00
[PM-5533] Migrate Asymmetric User Keys to State Providers (#7665)
This commit is contained in:
parent
7a6d7b3a68
commit
d8b74b78da
@ -225,8 +225,6 @@ export abstract class StateService<T extends Account = Account> {
|
|||||||
* @deprecated Do not call this, use PolicyService
|
* @deprecated Do not call this, use PolicyService
|
||||||
*/
|
*/
|
||||||
setDecryptedPolicies: (value: Policy[], options?: StorageOptions) => Promise<void>;
|
setDecryptedPolicies: (value: Policy[], options?: StorageOptions) => Promise<void>;
|
||||||
getDecryptedPrivateKey: (options?: StorageOptions) => Promise<Uint8Array>;
|
|
||||||
setDecryptedPrivateKey: (value: Uint8Array, options?: StorageOptions) => Promise<void>;
|
|
||||||
/**
|
/**
|
||||||
* @deprecated Do not call this directly, use SendService
|
* @deprecated Do not call this directly, use SendService
|
||||||
*/
|
*/
|
||||||
@ -346,8 +344,6 @@ export abstract class StateService<T extends Account = Account> {
|
|||||||
value: { [id: string]: PolicyData },
|
value: { [id: string]: PolicyData },
|
||||||
options?: StorageOptions,
|
options?: StorageOptions,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
getEncryptedPrivateKey: (options?: StorageOptions) => Promise<string>;
|
|
||||||
setEncryptedPrivateKey: (value: string, options?: StorageOptions) => Promise<void>;
|
|
||||||
/**
|
/**
|
||||||
* @deprecated Do not call this directly, use SendService
|
* @deprecated Do not call this directly, use SendService
|
||||||
*/
|
*/
|
||||||
@ -434,8 +430,6 @@ export abstract class StateService<T extends Account = Account> {
|
|||||||
setProtectedPin: (value: string, options?: StorageOptions) => Promise<void>;
|
setProtectedPin: (value: string, options?: StorageOptions) => Promise<void>;
|
||||||
getProviders: (options?: StorageOptions) => Promise<{ [id: string]: ProviderData }>;
|
getProviders: (options?: StorageOptions) => Promise<{ [id: string]: ProviderData }>;
|
||||||
setProviders: (value: { [id: string]: ProviderData }, options?: StorageOptions) => Promise<void>;
|
setProviders: (value: { [id: string]: ProviderData }, options?: StorageOptions) => Promise<void>;
|
||||||
getPublicKey: (options?: StorageOptions) => Promise<Uint8Array>;
|
|
||||||
setPublicKey: (value: Uint8Array, options?: StorageOptions) => Promise<void>;
|
|
||||||
getRefreshToken: (options?: StorageOptions) => Promise<string>;
|
getRefreshToken: (options?: StorageOptions) => Promise<string>;
|
||||||
setRefreshToken: (value: string, options?: StorageOptions) => Promise<void>;
|
setRefreshToken: (value: string, options?: StorageOptions) => Promise<void>;
|
||||||
getRememberedEmail: (options?: StorageOptions) => Promise<string>;
|
getRememberedEmail: (options?: StorageOptions) => Promise<string>;
|
||||||
|
@ -125,7 +125,6 @@ export class AccountKeys {
|
|||||||
masterKey?: MasterKey;
|
masterKey?: MasterKey;
|
||||||
masterKeyEncryptedUserKey?: string;
|
masterKeyEncryptedUserKey?: string;
|
||||||
deviceKey?: ReturnType<SymmetricCryptoKey["toJSON"]>;
|
deviceKey?: ReturnType<SymmetricCryptoKey["toJSON"]>;
|
||||||
privateKey?: EncryptionPair<string, Uint8Array> = new EncryptionPair<string, Uint8Array>();
|
|
||||||
publicKey?: Uint8Array;
|
publicKey?: Uint8Array;
|
||||||
apiKeyClientSecret?: string;
|
apiKeyClientSecret?: string;
|
||||||
|
|
||||||
@ -163,9 +162,6 @@ export class AccountKeys {
|
|||||||
obj?.cryptoSymmetricKey,
|
obj?.cryptoSymmetricKey,
|
||||||
SymmetricCryptoKey.fromJSON,
|
SymmetricCryptoKey.fromJSON,
|
||||||
),
|
),
|
||||||
privateKey: EncryptionPair.fromJSON<string, Uint8Array>(obj?.privateKey, (decObj: string) =>
|
|
||||||
Utils.fromByteStringToArray(decObj),
|
|
||||||
),
|
|
||||||
publicKey: Utils.fromByteStringToArray(obj?.publicKey),
|
publicKey: Utils.fromByteStringToArray(obj?.publicKey),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -9,7 +9,16 @@ import { AccountService } from "../../auth/abstractions/account.service";
|
|||||||
import { KdfConfig } from "../../auth/models/domain/kdf-config";
|
import { KdfConfig } from "../../auth/models/domain/kdf-config";
|
||||||
import { Utils } from "../../platform/misc/utils";
|
import { Utils } from "../../platform/misc/utils";
|
||||||
import { OrganizationId, ProviderId, UserId } from "../../types/guid";
|
import { OrganizationId, ProviderId, UserId } from "../../types/guid";
|
||||||
import { OrgKey, UserKey, MasterKey, ProviderKey, PinKey, CipherKey } from "../../types/key";
|
import {
|
||||||
|
OrgKey,
|
||||||
|
UserKey,
|
||||||
|
MasterKey,
|
||||||
|
ProviderKey,
|
||||||
|
PinKey,
|
||||||
|
CipherKey,
|
||||||
|
UserPrivateKey,
|
||||||
|
UserPublicKey,
|
||||||
|
} from "../../types/key";
|
||||||
import { CryptoFunctionService } from "../abstractions/crypto-function.service";
|
import { CryptoFunctionService } from "../abstractions/crypto-function.service";
|
||||||
import { CryptoService as CryptoServiceAbstraction } from "../abstractions/crypto.service";
|
import { CryptoService as CryptoServiceAbstraction } from "../abstractions/crypto.service";
|
||||||
import { EncryptService } from "../abstractions/encrypt.service";
|
import { EncryptService } from "../abstractions/encrypt.service";
|
||||||
@ -38,7 +47,12 @@ import {
|
|||||||
USER_ORGANIZATION_KEYS,
|
USER_ORGANIZATION_KEYS,
|
||||||
} from "./key-state/org-keys.state";
|
} from "./key-state/org-keys.state";
|
||||||
import { USER_ENCRYPTED_PROVIDER_KEYS, USER_PROVIDER_KEYS } from "./key-state/provider-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";
|
import {
|
||||||
|
USER_ENCRYPTED_PRIVATE_KEY,
|
||||||
|
USER_EVER_HAD_USER_KEY,
|
||||||
|
USER_PRIVATE_KEY,
|
||||||
|
USER_PUBLIC_KEY,
|
||||||
|
} from "./key-state/user-key.state";
|
||||||
|
|
||||||
export class CryptoService implements CryptoServiceAbstraction {
|
export class CryptoService implements CryptoServiceAbstraction {
|
||||||
private readonly activeUserEverHadUserKey: ActiveUserState<boolean>;
|
private readonly activeUserEverHadUserKey: ActiveUserState<boolean>;
|
||||||
@ -49,12 +63,16 @@ export class CryptoService implements CryptoServiceAbstraction {
|
|||||||
private readonly activeUserEncryptedProviderKeysState: ActiveUserState<
|
private readonly activeUserEncryptedProviderKeysState: ActiveUserState<
|
||||||
Record<ProviderId, EncryptedString>
|
Record<ProviderId, EncryptedString>
|
||||||
>;
|
>;
|
||||||
private readonly activeUserProviderKeysState: DerivedState<Record<OrganizationId, ProviderKey>>;
|
private readonly activeUserProviderKeysState: DerivedState<Record<ProviderId, ProviderKey>>;
|
||||||
|
private readonly activeUserEncryptedPrivateKeyState: ActiveUserState<EncryptedString>;
|
||||||
|
private readonly activeUserPrivateKeyState: DerivedState<UserPrivateKey>;
|
||||||
|
private readonly activeUserPublicKeyState: DerivedState<UserPublicKey>;
|
||||||
|
|
||||||
readonly activeUserOrgKeys$: Observable<Record<OrganizationId, OrgKey>>;
|
readonly activeUserOrgKeys$: Observable<Record<OrganizationId, OrgKey>>;
|
||||||
readonly activeUserProviderKeys$: Observable<Record<ProviderId, ProviderKey>>;
|
readonly activeUserProviderKeys$: Observable<Record<ProviderId, ProviderKey>>;
|
||||||
|
readonly activeUserPrivateKey$: Observable<UserPrivateKey>;
|
||||||
readonly everHadUserKey$;
|
readonly activeUserPublicKey$: Observable<UserPublicKey>;
|
||||||
|
readonly everHadUserKey$: Observable<boolean>;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
protected cryptoFunctionService: CryptoFunctionService,
|
protected cryptoFunctionService: CryptoFunctionService,
|
||||||
@ -65,7 +83,31 @@ export class CryptoService implements CryptoServiceAbstraction {
|
|||||||
protected accountService: AccountService,
|
protected accountService: AccountService,
|
||||||
protected stateProvider: StateProvider,
|
protected stateProvider: StateProvider,
|
||||||
) {
|
) {
|
||||||
|
// User Key
|
||||||
this.activeUserEverHadUserKey = stateProvider.getActive(USER_EVER_HAD_USER_KEY);
|
this.activeUserEverHadUserKey = stateProvider.getActive(USER_EVER_HAD_USER_KEY);
|
||||||
|
this.everHadUserKey$ = this.activeUserEverHadUserKey.state$.pipe(map((x) => x ?? false));
|
||||||
|
|
||||||
|
// User Asymmetric Key Pair
|
||||||
|
this.activeUserEncryptedPrivateKeyState = stateProvider.getActive(USER_ENCRYPTED_PRIVATE_KEY);
|
||||||
|
this.activeUserPrivateKeyState = stateProvider.getDerived(
|
||||||
|
this.activeUserEncryptedPrivateKeyState.combinedState$,
|
||||||
|
USER_PRIVATE_KEY,
|
||||||
|
{
|
||||||
|
encryptService: this.encryptService,
|
||||||
|
cryptoService: this,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
this.activeUserPrivateKey$ = this.activeUserPrivateKeyState.state$; // may be null
|
||||||
|
this.activeUserPublicKeyState = stateProvider.getDerived(
|
||||||
|
this.activeUserPrivateKey$,
|
||||||
|
USER_PUBLIC_KEY,
|
||||||
|
{
|
||||||
|
cryptoFunctionService: this.cryptoFunctionService,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
this.activeUserPublicKey$ = this.activeUserPublicKeyState.state$; // may be null
|
||||||
|
|
||||||
|
// Organization keys
|
||||||
this.activeUserEncryptedOrgKeysState = stateProvider.getActive(
|
this.activeUserEncryptedOrgKeysState = stateProvider.getActive(
|
||||||
USER_ENCRYPTED_ORGANIZATION_KEYS,
|
USER_ENCRYPTED_ORGANIZATION_KEYS,
|
||||||
);
|
);
|
||||||
@ -74,6 +116,9 @@ export class CryptoService implements CryptoServiceAbstraction {
|
|||||||
USER_ORGANIZATION_KEYS,
|
USER_ORGANIZATION_KEYS,
|
||||||
{ cryptoService: this },
|
{ cryptoService: this },
|
||||||
);
|
);
|
||||||
|
this.activeUserOrgKeys$ = this.activeUserOrgKeysState.state$; // null handled by `derive` function
|
||||||
|
|
||||||
|
// Provider keys
|
||||||
this.activeUserEncryptedProviderKeysState = stateProvider.getActive(
|
this.activeUserEncryptedProviderKeysState = stateProvider.getActive(
|
||||||
USER_ENCRYPTED_PROVIDER_KEYS,
|
USER_ENCRYPTED_PROVIDER_KEYS,
|
||||||
);
|
);
|
||||||
@ -82,9 +127,6 @@ export class CryptoService implements CryptoServiceAbstraction {
|
|||||||
USER_PROVIDER_KEYS,
|
USER_PROVIDER_KEYS,
|
||||||
{ encryptService: this.encryptService, cryptoService: this },
|
{ 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
|
this.activeUserProviderKeys$ = this.activeUserProviderKeysState.state$; // null handled by `derive` function
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -465,19 +507,7 @@ export class CryptoService implements CryptoServiceAbstraction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getPublicKey(): Promise<Uint8Array> {
|
async getPublicKey(): Promise<Uint8Array> {
|
||||||
const inMemoryPublicKey = await this.stateService.getPublicKey();
|
return await firstValueFrom(this.activeUserPublicKey$);
|
||||||
if (inMemoryPublicKey != null) {
|
|
||||||
return inMemoryPublicKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
const privateKey = await this.getPrivateKey();
|
|
||||||
if (privateKey == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const publicKey = await this.cryptoFunctionService.rsaExtractPublicKey(privateKey);
|
|
||||||
await this.stateService.setPublicKey(publicKey);
|
|
||||||
return publicKey;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async makeOrgKey<T extends OrgKey | ProviderKey>(): Promise<[EncString, T]> {
|
async makeOrgKey<T extends OrgKey | ProviderKey>(): Promise<[EncString, T]> {
|
||||||
@ -487,32 +517,16 @@ export class CryptoService implements CryptoServiceAbstraction {
|
|||||||
return [encShareKey, new SymmetricCryptoKey(shareKey) as T];
|
return [encShareKey, new SymmetricCryptoKey(shareKey) as T];
|
||||||
}
|
}
|
||||||
|
|
||||||
async setPrivateKey(encPrivateKey: string): Promise<void> {
|
async setPrivateKey(encPrivateKey: EncryptedString): Promise<void> {
|
||||||
if (encPrivateKey == null) {
|
if (encPrivateKey == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.stateService.setDecryptedPrivateKey(null);
|
await this.activeUserEncryptedPrivateKeyState.update(() => encPrivateKey);
|
||||||
await this.stateService.setEncryptedPrivateKey(encPrivateKey);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPrivateKey(): Promise<Uint8Array> {
|
async getPrivateKey(): Promise<Uint8Array> {
|
||||||
const decryptedPrivateKey = await this.stateService.getDecryptedPrivateKey();
|
return await firstValueFrom(this.activeUserPrivateKey$);
|
||||||
if (decryptedPrivateKey != null) {
|
|
||||||
return decryptedPrivateKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
const encPrivateKey = await this.stateService.getEncryptedPrivateKey();
|
|
||||||
if (encPrivateKey == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const privateKey = await this.encryptService.decryptToBytes(
|
|
||||||
new EncString(encPrivateKey),
|
|
||||||
await this.getUserKeyWithLegacySupport(),
|
|
||||||
);
|
|
||||||
await this.stateService.setDecryptedPrivateKey(privateKey);
|
|
||||||
return privateKey;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getFingerprint(fingerprintMaterial: string, publicKey?: Uint8Array): Promise<string[]> {
|
async getFingerprint(fingerprintMaterial: string, publicKey?: Uint8Array): Promise<string[]> {
|
||||||
@ -543,14 +557,23 @@ export class CryptoService implements CryptoServiceAbstraction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async clearKeyPair(memoryOnly?: boolean, userId?: UserId): Promise<void[]> {
|
async clearKeyPair(memoryOnly?: boolean, userId?: UserId): Promise<void[]> {
|
||||||
const keysToClear: Promise<void>[] = [
|
const activeUserId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
|
||||||
this.stateService.setDecryptedPrivateKey(null, { userId: userId }),
|
const userIdIsActive = userId == null || userId === activeUserId;
|
||||||
this.stateService.setPublicKey(null, { userId: userId }),
|
if (memoryOnly && userIdIsActive) {
|
||||||
];
|
// key pair is only cached for active users
|
||||||
if (!memoryOnly) {
|
await this.activeUserPrivateKeyState.forceValue(null);
|
||||||
keysToClear.push(this.stateService.setEncryptedPrivateKey(null, { userId: userId }));
|
await this.activeUserPublicKeyState.forceValue(null);
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
if (userId == null && activeUserId == null) {
|
||||||
|
// nothing to do
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// below updates decrypted private key and public keys if this is the active user as well since those are derived from the encrypted private key
|
||||||
|
await this.stateProvider
|
||||||
|
.getUser(userId ?? activeUserId, USER_ENCRYPTED_PRIVATE_KEY)
|
||||||
|
.update(() => null);
|
||||||
}
|
}
|
||||||
return Promise.all(keysToClear);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async makePinKey(pin: string, salt: string, kdf: KdfType, kdfConfig: KdfConfig): Promise<PinKey> {
|
async makePinKey(pin: string, salt: string, kdf: KdfType, kdfConfig: KdfConfig): Promise<PinKey> {
|
||||||
@ -735,16 +758,23 @@ export class CryptoService implements CryptoServiceAbstraction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const encPrivateKey = await this.stateService.getEncryptedPrivateKey();
|
const [userId, encPrivateKey] = await firstValueFrom(
|
||||||
|
this.activeUserEncryptedPrivateKeyState.combinedState$,
|
||||||
|
);
|
||||||
if (encPrivateKey == null) {
|
if (encPrivateKey == null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const privateKey = await this.encryptService.decryptToBytes(
|
// Can decrypt private key
|
||||||
new EncString(encPrivateKey),
|
const privateKey = await USER_PRIVATE_KEY.derive([userId, encPrivateKey], {
|
||||||
key,
|
encryptService: this.encryptService,
|
||||||
);
|
cryptoService: this,
|
||||||
await this.cryptoFunctionService.rsaExtractPublicKey(privateKey);
|
});
|
||||||
|
|
||||||
|
// Can successfully derive public key
|
||||||
|
await USER_PUBLIC_KEY.derive(privateKey, {
|
||||||
|
cryptoFunctionService: this.cryptoFunctionService,
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -765,7 +795,7 @@ export class CryptoService implements CryptoServiceAbstraction {
|
|||||||
const userKey = new SymmetricCryptoKey(rawKey) as UserKey;
|
const userKey = new SymmetricCryptoKey(rawKey) as UserKey;
|
||||||
const [publicKey, privateKey] = await this.makeKeyPair(userKey);
|
const [publicKey, privateKey] = await this.makeKeyPair(userKey);
|
||||||
await this.setUserKey(userKey);
|
await this.setUserKey(userKey);
|
||||||
await this.stateService.setEncryptedPrivateKey(privateKey.encryptedString);
|
await this.activeUserEncryptedPrivateKeyState.update(() => privateKey.encryptedString);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
userKey,
|
userKey,
|
||||||
|
@ -0,0 +1,130 @@
|
|||||||
|
import { mock } from "jest-mock-extended";
|
||||||
|
|
||||||
|
import { makeStaticByteArray } from "../../../../spec";
|
||||||
|
import { UserId } from "../../../types/guid";
|
||||||
|
import { UserKey, UserPrivateKey, UserPublicKey } from "../../../types/key";
|
||||||
|
import { CryptoFunctionService } from "../../abstractions/crypto-function.service";
|
||||||
|
import { EncryptService } from "../../abstractions/encrypt.service";
|
||||||
|
import { EncryptionType } from "../../enums";
|
||||||
|
import { Utils } from "../../misc/utils";
|
||||||
|
import { EncString } from "../../models/domain/enc-string";
|
||||||
|
import { CryptoService } from "../crypto.service";
|
||||||
|
|
||||||
|
import {
|
||||||
|
USER_ENCRYPTED_PRIVATE_KEY,
|
||||||
|
USER_EVER_HAD_USER_KEY,
|
||||||
|
USER_PRIVATE_KEY,
|
||||||
|
USER_PUBLIC_KEY,
|
||||||
|
} from "./user-key.state";
|
||||||
|
|
||||||
|
function makeEncString(data?: string) {
|
||||||
|
data ??= Utils.newGuid();
|
||||||
|
return new EncString(EncryptionType.AesCbc256_HmacSha256_B64, data, "test", "test");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Ever had user key", () => {
|
||||||
|
const sut = USER_EVER_HAD_USER_KEY;
|
||||||
|
|
||||||
|
it("should deserialize ever had user key", () => {
|
||||||
|
const everHadUserKey = true;
|
||||||
|
|
||||||
|
const result = sut.deserializer(JSON.parse(JSON.stringify(everHadUserKey)));
|
||||||
|
|
||||||
|
expect(result).toEqual(everHadUserKey);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Encrypted private key", () => {
|
||||||
|
const sut = USER_ENCRYPTED_PRIVATE_KEY;
|
||||||
|
|
||||||
|
it("should deserialize encrypted private key", () => {
|
||||||
|
const encryptedPrivateKey = makeEncString().encryptedString;
|
||||||
|
|
||||||
|
const result = sut.deserializer(JSON.parse(JSON.stringify(encryptedPrivateKey)));
|
||||||
|
|
||||||
|
expect(result).toEqual(encryptedPrivateKey);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("User public key", () => {
|
||||||
|
const sut = USER_PUBLIC_KEY;
|
||||||
|
const userPrivateKey = makeStaticByteArray(64, 1) as UserPrivateKey;
|
||||||
|
const userPublicKey = makeStaticByteArray(64, 2) as UserPublicKey;
|
||||||
|
|
||||||
|
it("should deserialize user public key", () => {
|
||||||
|
const userPublicKey = makeStaticByteArray(64, 1);
|
||||||
|
|
||||||
|
const result = sut.deserialize(JSON.parse(JSON.stringify(userPublicKey)));
|
||||||
|
|
||||||
|
expect(result).toEqual(userPublicKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should derive user public key", async () => {
|
||||||
|
const cryptoFunctionService = mock<CryptoFunctionService>();
|
||||||
|
cryptoFunctionService.rsaExtractPublicKey.mockResolvedValue(userPublicKey);
|
||||||
|
|
||||||
|
const result = await sut.derive(userPrivateKey, { cryptoFunctionService });
|
||||||
|
|
||||||
|
expect(result).toEqual(userPublicKey);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Derived decrypted private key", () => {
|
||||||
|
const sut = USER_PRIVATE_KEY;
|
||||||
|
const userId = "userId" as UserId;
|
||||||
|
const userKey = mock<UserKey>();
|
||||||
|
const encryptedPrivateKey = makeEncString().encryptedString;
|
||||||
|
const decryptedPrivateKey = makeStaticByteArray(64, 1);
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should deserialize decrypted private key", () => {
|
||||||
|
const decryptedPrivateKey = makeStaticByteArray(64, 1);
|
||||||
|
|
||||||
|
const result = sut.deserialize(JSON.parse(JSON.stringify(decryptedPrivateKey)));
|
||||||
|
|
||||||
|
expect(result).toEqual(decryptedPrivateKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should derive decrypted private key", async () => {
|
||||||
|
const cryptoService = mock<CryptoService>();
|
||||||
|
cryptoService.getUserKey.mockResolvedValue(userKey);
|
||||||
|
const encryptService = mock<EncryptService>();
|
||||||
|
encryptService.decryptToBytes.mockResolvedValue(decryptedPrivateKey);
|
||||||
|
|
||||||
|
const result = await sut.derive([userId, encryptedPrivateKey], {
|
||||||
|
encryptService,
|
||||||
|
cryptoService,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual(decryptedPrivateKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle null input values", async () => {
|
||||||
|
const cryptoService = mock<CryptoService>();
|
||||||
|
cryptoService.getUserKey.mockResolvedValue(userKey);
|
||||||
|
const encryptService = mock<EncryptService>();
|
||||||
|
|
||||||
|
const result = await sut.derive([userId, null], {
|
||||||
|
encryptService,
|
||||||
|
cryptoService,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle null user key", async () => {
|
||||||
|
const cryptoService = mock<CryptoService>();
|
||||||
|
cryptoService.getUserKey.mockResolvedValue(null);
|
||||||
|
const encryptService = mock<EncryptService>();
|
||||||
|
|
||||||
|
const result = await sut.derive([userId, encryptedPrivateKey], {
|
||||||
|
encryptService,
|
||||||
|
cryptoService,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual(null);
|
||||||
|
});
|
||||||
|
});
|
@ -1,5 +1,59 @@
|
|||||||
import { KeyDefinition, CRYPTO_DISK } from "../../state";
|
import { UserPrivateKey, UserPublicKey } from "../../../types/key";
|
||||||
|
import { CryptoFunctionService } from "../../abstractions/crypto-function.service";
|
||||||
|
import { EncryptService } from "../../abstractions/encrypt.service";
|
||||||
|
import { EncString, EncryptedString } from "../../models/domain/enc-string";
|
||||||
|
import { KeyDefinition, CRYPTO_DISK, DeriveDefinition } from "../../state";
|
||||||
|
import { CryptoService } from "../crypto.service";
|
||||||
|
|
||||||
export const USER_EVER_HAD_USER_KEY = new KeyDefinition<boolean>(CRYPTO_DISK, "everHadUserKey", {
|
export const USER_EVER_HAD_USER_KEY = new KeyDefinition<boolean>(CRYPTO_DISK, "everHadUserKey", {
|
||||||
deserializer: (obj) => obj,
|
deserializer: (obj) => obj,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const USER_ENCRYPTED_PRIVATE_KEY = new KeyDefinition<EncryptedString>(
|
||||||
|
CRYPTO_DISK,
|
||||||
|
"privateKey",
|
||||||
|
{
|
||||||
|
deserializer: (obj) => obj,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const USER_PRIVATE_KEY = DeriveDefinition.fromWithUserId<
|
||||||
|
EncryptedString,
|
||||||
|
UserPrivateKey,
|
||||||
|
// TODO: update cryptoService to user key directly
|
||||||
|
{ encryptService: EncryptService; cryptoService: CryptoService }
|
||||||
|
>(USER_ENCRYPTED_PRIVATE_KEY, {
|
||||||
|
deserializer: (obj) => new Uint8Array(Object.values(obj)) as UserPrivateKey,
|
||||||
|
derive: async ([userId, encPrivateKeyString], { encryptService, cryptoService }) => {
|
||||||
|
if (encPrivateKeyString == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userKey = await cryptoService.getUserKey(userId);
|
||||||
|
if (userKey == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const encPrivateKey = new EncString(encPrivateKeyString);
|
||||||
|
const privateKey = (await encryptService.decryptToBytes(
|
||||||
|
encPrivateKey,
|
||||||
|
userKey,
|
||||||
|
)) as UserPrivateKey;
|
||||||
|
return privateKey;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const USER_PUBLIC_KEY = DeriveDefinition.from<
|
||||||
|
UserPrivateKey,
|
||||||
|
UserPublicKey,
|
||||||
|
{ cryptoFunctionService: CryptoFunctionService }
|
||||||
|
>([USER_PRIVATE_KEY, "publicKey"], {
|
||||||
|
deserializer: (obj) => new Uint8Array(Object.values(obj)) as UserPublicKey,
|
||||||
|
derive: async (privateKey, { cryptoFunctionService }) => {
|
||||||
|
if (privateKey == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await cryptoFunctionService.rsaExtractPublicKey(privateKey)) as UserPublicKey;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
@ -1048,23 +1048,6 @@ export class StateService<
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDecryptedPrivateKey(options?: StorageOptions): Promise<Uint8Array> {
|
|
||||||
return (
|
|
||||||
await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions()))
|
|
||||||
)?.keys?.privateKey.decrypted;
|
|
||||||
}
|
|
||||||
|
|
||||||
async setDecryptedPrivateKey(value: Uint8Array, options?: StorageOptions): Promise<void> {
|
|
||||||
const account = await this.getAccount(
|
|
||||||
this.reconcileOptions(options, await this.defaultInMemoryOptions()),
|
|
||||||
);
|
|
||||||
account.keys.privateKey.decrypted = value;
|
|
||||||
await this.saveAccount(
|
|
||||||
account,
|
|
||||||
this.reconcileOptions(options, await this.defaultInMemoryOptions()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@withPrototypeForArrayMembers(SendView)
|
@withPrototypeForArrayMembers(SendView)
|
||||||
async getDecryptedSends(options?: StorageOptions): Promise<SendView[]> {
|
async getDecryptedSends(options?: StorageOptions): Promise<SendView[]> {
|
||||||
return (
|
return (
|
||||||
@ -1752,24 +1735,6 @@ export class StateService<
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getEncryptedPrivateKey(options?: StorageOptions): Promise<string> {
|
|
||||||
const account = await this.getAccount(
|
|
||||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
|
||||||
);
|
|
||||||
return account?.keys?.privateKey?.encrypted;
|
|
||||||
}
|
|
||||||
|
|
||||||
async setEncryptedPrivateKey(value: string, options?: StorageOptions): Promise<void> {
|
|
||||||
const account = await this.getAccount(
|
|
||||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
|
||||||
);
|
|
||||||
account.keys.privateKey.encrypted = value;
|
|
||||||
await this.saveAccount(
|
|
||||||
account,
|
|
||||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@withPrototypeForObjectValues(SendData)
|
@withPrototypeForObjectValues(SendData)
|
||||||
async getEncryptedSends(options?: StorageOptions): Promise<{ [id: string]: SendData }> {
|
async getEncryptedSends(options?: StorageOptions): Promise<{ [id: string]: SendData }> {
|
||||||
return (
|
return (
|
||||||
@ -2274,24 +2239,6 @@ export class StateService<
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPublicKey(options?: StorageOptions): Promise<Uint8Array> {
|
|
||||||
const keys = (
|
|
||||||
await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions()))
|
|
||||||
)?.keys;
|
|
||||||
return keys?.publicKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
async setPublicKey(value: Uint8Array, options?: StorageOptions): Promise<void> {
|
|
||||||
const account = await this.getAccount(
|
|
||||||
this.reconcileOptions(options, await this.defaultInMemoryOptions()),
|
|
||||||
);
|
|
||||||
account.keys.publicKey = value;
|
|
||||||
await this.saveAccount(
|
|
||||||
account,
|
|
||||||
this.reconcileOptions(options, await this.defaultInMemoryOptions()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getRefreshToken(options?: StorageOptions): Promise<string> {
|
async getRefreshToken(options?: StorageOptions): Promise<string> {
|
||||||
options = await this.getTimeoutBasedStorageOptions(options);
|
options = await this.getTimeoutBasedStorageOptions(options);
|
||||||
return (await this.getAccount(options))?.tokens?.refreshToken;
|
return (await this.getAccount(options))?.tokens?.refreshToken;
|
||||||
|
42
libs/common/src/platform/state/derive-definition.spec.ts
Normal file
42
libs/common/src/platform/state/derive-definition.spec.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { DeriveDefinition } from "./derive-definition";
|
||||||
|
import { KeyDefinition } from "./key-definition";
|
||||||
|
import { StateDefinition } from "./state-definition";
|
||||||
|
|
||||||
|
const derive: () => any = () => null;
|
||||||
|
const deserializer: any = (obj: any) => obj;
|
||||||
|
|
||||||
|
const STATE_DEFINITION = new StateDefinition("test", "disk");
|
||||||
|
const TEST_KEY = new KeyDefinition(STATE_DEFINITION, "test", {
|
||||||
|
deserializer,
|
||||||
|
});
|
||||||
|
const TEST_DERIVE = new DeriveDefinition(STATE_DEFINITION, "test", {
|
||||||
|
derive,
|
||||||
|
deserializer,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("DeriveDefinition", () => {
|
||||||
|
describe("from", () => {
|
||||||
|
it("should create a new DeriveDefinition from a KeyDefinition", () => {
|
||||||
|
const result = DeriveDefinition.from(TEST_KEY, {
|
||||||
|
derive,
|
||||||
|
deserializer,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual(TEST_DERIVE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create a new DeriveDefinition from a DeriveDefinition", () => {
|
||||||
|
const result = DeriveDefinition.from([TEST_DERIVE, "newDerive"], {
|
||||||
|
derive,
|
||||||
|
deserializer,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual(
|
||||||
|
new DeriveDefinition(STATE_DEFINITION, "newDerive", {
|
||||||
|
derive,
|
||||||
|
deserializer,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -1,5 +1,6 @@
|
|||||||
import { Jsonify } from "type-fest";
|
import { Jsonify } from "type-fest";
|
||||||
|
|
||||||
|
import { UserId } from "../../types/guid";
|
||||||
import { DerivedStateDependencies, StorageKey } from "../../types/state";
|
import { DerivedStateDependencies, StorageKey } from "../../types/state";
|
||||||
|
|
||||||
import { KeyDefinition } from "./key-definition";
|
import { KeyDefinition } from "./key-definition";
|
||||||
@ -95,18 +96,60 @@ export class DeriveDefinition<TFrom, TTo, TDeps extends DerivedStateDependencies
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Factory that produces a {@link DeriveDefinition} from a {@link KeyDefinition} and a set of options. The returned
|
* Factory that produces a {@link DeriveDefinition} from a {@link KeyDefinition} or {@link DeriveDefinition} and new name.
|
||||||
* definition will have the same key as the given key definition, but will not collide with it in storage, even if
|
*
|
||||||
* they both reside in memory.
|
* If a `KeyDefinition` is passed in, the returned definition will have the same key as the given key definition, but
|
||||||
* @param keyDefinition
|
* will not collide with it in storage, even if they both reside in memory.
|
||||||
|
*
|
||||||
|
* If a `DeriveDefinition` is passed in, the returned definition will instead use the name given in the second position
|
||||||
|
* of the tuple. It is up to you to ensure this is unique within the domain of derived state.
|
||||||
|
*
|
||||||
|
* @param options A set of options to customize the behavior of {@link DeriveDefinition}.
|
||||||
|
* @param options.derive A function to use to convert values from TFrom to TTo. This is called on each emit of the parent state observable
|
||||||
|
* and the resulting value will be emitted from the derived state observable.
|
||||||
|
* @param options.cleanupDelayMs The number of milliseconds to wait before cleaning up the state after the last subscriber has unsubscribed.
|
||||||
|
* Defaults to 1000ms.
|
||||||
|
* @param options.dependencyShape An object defining the dependencies of the derive function. The keys of the object are the names of the dependencies
|
||||||
|
* and the values are the types of the dependencies.
|
||||||
|
* for example:
|
||||||
|
* ```
|
||||||
|
* {
|
||||||
|
* myService: MyService,
|
||||||
|
* myOtherService: MyOtherService,
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @param options.deserializer A function to use to safely convert your type from json to your expected type.
|
||||||
|
* Your data may be serialized/deserialized at any time and this needs callback needs to be able to faithfully re-initialize
|
||||||
|
* from the JSON object representation of your type.
|
||||||
|
* @param definition
|
||||||
* @param options
|
* @param options
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
static from<TFrom, TTo, TDeps extends DerivedStateDependencies = never>(
|
static from<TFrom, TTo, TDeps extends DerivedStateDependencies = never>(
|
||||||
keyDefinition: KeyDefinition<TFrom>,
|
definition:
|
||||||
|
| KeyDefinition<TFrom>
|
||||||
|
| [DeriveDefinition<unknown, TFrom, DerivedStateDependencies>, string],
|
||||||
options: DeriveDefinitionOptions<TFrom, TTo, TDeps>,
|
options: DeriveDefinitionOptions<TFrom, TTo, TDeps>,
|
||||||
) {
|
) {
|
||||||
return new DeriveDefinition(keyDefinition.stateDefinition, keyDefinition.key, options);
|
if (isKeyDefinition(definition)) {
|
||||||
|
return new DeriveDefinition(definition.stateDefinition, definition.key, options);
|
||||||
|
} else {
|
||||||
|
return new DeriveDefinition(definition[0].stateDefinition, definition[1], options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromWithUserId<TKeyDef, TTo, TDeps extends DerivedStateDependencies = never>(
|
||||||
|
definition:
|
||||||
|
| KeyDefinition<TKeyDef>
|
||||||
|
| [DeriveDefinition<unknown, TKeyDef, DerivedStateDependencies>, string],
|
||||||
|
options: DeriveDefinitionOptions<[UserId, TKeyDef], TTo, TDeps>,
|
||||||
|
) {
|
||||||
|
if (isKeyDefinition(definition)) {
|
||||||
|
return new DeriveDefinition(definition.stateDefinition, definition.key, options);
|
||||||
|
} else {
|
||||||
|
return new DeriveDefinition(definition[0].stateDefinition, definition[1], options);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get derive() {
|
get derive() {
|
||||||
@ -137,3 +180,11 @@ export class DeriveDefinition<TFrom, TTo, TDeps extends DerivedStateDependencies
|
|||||||
return `derived_${this.stateDefinition.name}_${this.uniqueDerivationName}` as StorageKey;
|
return `derived_${this.stateDefinition.name}_${this.uniqueDerivationName}` as StorageKey;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isKeyDefinition(
|
||||||
|
definition:
|
||||||
|
| KeyDefinition<unknown>
|
||||||
|
| [DeriveDefinition<unknown, unknown, DerivedStateDependencies>, string],
|
||||||
|
): definition is KeyDefinition<unknown> {
|
||||||
|
return Object.prototype.hasOwnProperty.call(definition, "key");
|
||||||
|
}
|
||||||
|
@ -49,7 +49,7 @@ export abstract class StateProvider {
|
|||||||
getGlobal: <T>(keyDefinition: KeyDefinition<T>) => GlobalState<T>;
|
getGlobal: <T>(keyDefinition: KeyDefinition<T>) => GlobalState<T>;
|
||||||
getDerived: <TFrom, TTo, TDeps extends DerivedStateDependencies>(
|
getDerived: <TFrom, TTo, TDeps extends DerivedStateDependencies>(
|
||||||
parentState$: Observable<TFrom>,
|
parentState$: Observable<TFrom>,
|
||||||
deriveDefinition: DeriveDefinition<unknown, TTo, TDeps>,
|
deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>,
|
||||||
dependencies: TDeps,
|
dependencies: TDeps,
|
||||||
) => DerivedState<TTo>;
|
) => DerivedState<TTo>;
|
||||||
}
|
}
|
||||||
|
@ -14,6 +14,7 @@ import { LastSyncMigrator } from "./migrations/16-move-last-sync-to-state-provid
|
|||||||
import { EnablePasskeysMigrator } from "./migrations/17-move-enable-passkeys-to-state-providers";
|
import { EnablePasskeysMigrator } from "./migrations/17-move-enable-passkeys-to-state-providers";
|
||||||
import { AutofillSettingsKeyMigrator } from "./migrations/18-move-autofill-settings-to-state-providers";
|
import { AutofillSettingsKeyMigrator } from "./migrations/18-move-autofill-settings-to-state-providers";
|
||||||
import { RequirePasswordOnStartMigrator } from "./migrations/19-migrate-require-password-on-start";
|
import { RequirePasswordOnStartMigrator } from "./migrations/19-migrate-require-password-on-start";
|
||||||
|
import { PrivateKeyMigrator } from "./migrations/20-move-private-key-to-state-providers";
|
||||||
import { FixPremiumMigrator } from "./migrations/3-fix-premium";
|
import { FixPremiumMigrator } from "./migrations/3-fix-premium";
|
||||||
import { RemoveEverBeenUnlockedMigrator } from "./migrations/4-remove-ever-been-unlocked";
|
import { RemoveEverBeenUnlockedMigrator } from "./migrations/4-remove-ever-been-unlocked";
|
||||||
import { AddKeyTypeToOrgKeysMigrator } from "./migrations/5-add-key-type-to-org-keys";
|
import { AddKeyTypeToOrgKeysMigrator } from "./migrations/5-add-key-type-to-org-keys";
|
||||||
@ -24,7 +25,7 @@ import { MoveBrowserSettingsToGlobal } from "./migrations/9-move-browser-setting
|
|||||||
import { MinVersionMigrator } from "./migrations/min-version";
|
import { MinVersionMigrator } from "./migrations/min-version";
|
||||||
|
|
||||||
export const MIN_VERSION = 2;
|
export const MIN_VERSION = 2;
|
||||||
export const CURRENT_VERSION = 19;
|
export const CURRENT_VERSION = 20;
|
||||||
export type MinVersion = typeof MIN_VERSION;
|
export type MinVersion = typeof MIN_VERSION;
|
||||||
|
|
||||||
export function createMigrationBuilder() {
|
export function createMigrationBuilder() {
|
||||||
@ -46,7 +47,8 @@ export function createMigrationBuilder() {
|
|||||||
.with(LastSyncMigrator, 15, 16)
|
.with(LastSyncMigrator, 15, 16)
|
||||||
.with(EnablePasskeysMigrator, 16, 17)
|
.with(EnablePasskeysMigrator, 16, 17)
|
||||||
.with(AutofillSettingsKeyMigrator, 17, 18)
|
.with(AutofillSettingsKeyMigrator, 17, 18)
|
||||||
.with(RequirePasswordOnStartMigrator, 18, CURRENT_VERSION);
|
.with(RequirePasswordOnStartMigrator, 18, 19)
|
||||||
|
.with(PrivateKeyMigrator, 19, CURRENT_VERSION);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function currentVersion(
|
export async function currentVersion(
|
||||||
|
@ -0,0 +1,127 @@
|
|||||||
|
import { MockProxy, any } from "jest-mock-extended";
|
||||||
|
|
||||||
|
import { MigrationHelper } from "../migration-helper";
|
||||||
|
import { mockMigrationHelper } from "../migration-helper.spec";
|
||||||
|
|
||||||
|
import { PrivateKeyMigrator } from "./20-move-private-key-to-state-providers";
|
||||||
|
|
||||||
|
function exampleJSON() {
|
||||||
|
return {
|
||||||
|
global: {
|
||||||
|
otherStuff: "otherStuff1",
|
||||||
|
},
|
||||||
|
authenticatedAccounts: ["user-1", "user-2", "user-3"],
|
||||||
|
"user-1": {
|
||||||
|
keys: {
|
||||||
|
privateKey: {
|
||||||
|
encrypted: "user-1-encrypted-private-key",
|
||||||
|
},
|
||||||
|
otherStuff: "overStuff2",
|
||||||
|
},
|
||||||
|
otherStuff: "otherStuff3",
|
||||||
|
},
|
||||||
|
"user-2": {
|
||||||
|
keys: {
|
||||||
|
otherStuff: "otherStuff4",
|
||||||
|
},
|
||||||
|
otherStuff: "otherStuff5",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function rollbackJSON() {
|
||||||
|
return {
|
||||||
|
"user_user-1_crypto_privateKey": "encrypted-private-key",
|
||||||
|
"user_user-2_crypto_privateKey": 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("privateKeyMigrator", () => {
|
||||||
|
let helper: MockProxy<MigrationHelper>;
|
||||||
|
let sut: PrivateKeyMigrator;
|
||||||
|
const keyDefinitionLike = {
|
||||||
|
key: "privateKey",
|
||||||
|
stateDefinition: {
|
||||||
|
name: "crypto",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("migrate", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
helper = mockMigrationHelper(exampleJSON(), 19);
|
||||||
|
sut = new PrivateKeyMigrator(19, 20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should remove privateKey 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 privateKey value for each account", async () => {
|
||||||
|
await sut.migrate(helper);
|
||||||
|
|
||||||
|
expect(helper.setToUser).toHaveBeenCalledTimes(1);
|
||||||
|
expect(helper.setToUser).toHaveBeenCalledWith(
|
||||||
|
"user-1",
|
||||||
|
keyDefinitionLike,
|
||||||
|
"user-1-encrypted-private-key",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("rollback", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
helper = mockMigrationHelper(rollbackJSON(), 20);
|
||||||
|
sut = new PrivateKeyMigrator(19, 20);
|
||||||
|
});
|
||||||
|
|
||||||
|
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: {
|
||||||
|
privateKey: {
|
||||||
|
encrypted: "encrypted-private-key",
|
||||||
|
},
|
||||||
|
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?: {
|
||||||
|
privateKey?: {
|
||||||
|
encrypted?: string; // EncryptedString
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const USER_ENCRYPTED_PRIVATE_KEY: KeyDefinitionLike = {
|
||||||
|
key: "privateKey",
|
||||||
|
stateDefinition: {
|
||||||
|
name: "crypto",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export class PrivateKeyMigrator extends Migrator<19, 20> {
|
||||||
|
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?.privateKey?.encrypted;
|
||||||
|
if (value != null) {
|
||||||
|
await helper.setToUser(userId, USER_ENCRYPTED_PRIVATE_KEY, value);
|
||||||
|
delete account.keys.privateKey;
|
||||||
|
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_PRIVATE_KEY,
|
||||||
|
);
|
||||||
|
if (account && value) {
|
||||||
|
account.keys = Object.assign(account.keys ?? {}, {
|
||||||
|
privateKey: {
|
||||||
|
encrypted: value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await helper.set(userId, account);
|
||||||
|
}
|
||||||
|
await helper.setToUser(userId, USER_ENCRYPTED_PRIVATE_KEY, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]);
|
||||||
|
}
|
||||||
|
}
|
@ -14,3 +14,4 @@ export type CipherKey = Opaque<SymmetricCryptoKey, "CipherKey">;
|
|||||||
|
|
||||||
// asymmetric keys
|
// asymmetric keys
|
||||||
export type UserPrivateKey = Opaque<Uint8Array, "UserPrivateKey">;
|
export type UserPrivateKey = Opaque<Uint8Array, "UserPrivateKey">;
|
||||||
|
export type UserPublicKey = Opaque<Uint8Array, "UserPublicKey">;
|
||||||
|
Loading…
Reference in New Issue
Block a user