1
0
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:
Matt Gibson 2024-02-14 15:04:08 -05:00 committed by GitHub
parent 7a6d7b3a68
commit d8b74b78da
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 554 additions and 127 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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