mirror of
https://github.com/bitwarden/browser
synced 2025-12-14 15:23:33 +00:00
Rework Desktop Biometrics (#5234)
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { DecryptParameters } from "../models/domain/decrypt-parameters";
|
||||
import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key";
|
||||
import { CsprngArray } from "../types/csprng";
|
||||
|
||||
export abstract class CryptoFunctionService {
|
||||
pbkdf2: (
|
||||
@@ -65,5 +66,5 @@ export abstract class CryptoFunctionService {
|
||||
) => Promise<ArrayBuffer>;
|
||||
rsaExtractPublicKey: (privateKey: ArrayBuffer) => Promise<ArrayBuffer>;
|
||||
rsaGenerateKeyPair: (length: 1024 | 2048 | 4096) => Promise<[ArrayBuffer, ArrayBuffer]>;
|
||||
randomBytes: (length: number) => Promise<ArrayBuffer>;
|
||||
randomBytes: (length: number) => Promise<CsprngArray>;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { CollectionView } from "../admin-console/models/view/collection.view";
|
||||
import { EnvironmentUrls } from "../auth/models/domain/environment-urls";
|
||||
import { ForceResetPasswordReason } from "../auth/models/domain/force-reset-password-reason";
|
||||
import { KdfConfig } from "../auth/models/domain/kdf-config";
|
||||
import { BiometricKey } from "../auth/types/biometric-key";
|
||||
import { KdfType, ThemeType, UriMatchType } from "../enums";
|
||||
import { EventData } from "../models/data/event.data";
|
||||
import { ServerConfigData } from "../models/data/server-config.data";
|
||||
@@ -78,7 +79,7 @@ export abstract class StateService<T extends Account = Account> {
|
||||
setCryptoMasterKeyB64: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
getCryptoMasterKeyBiometric: (options?: StorageOptions) => Promise<string>;
|
||||
hasCryptoMasterKeyBiometric: (options?: StorageOptions) => Promise<boolean>;
|
||||
setCryptoMasterKeyBiometric: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
setCryptoMasterKeyBiometric: (value: BiometricKey, options?: StorageOptions) => Promise<void>;
|
||||
getDecryptedCiphers: (options?: StorageOptions) => Promise<CipherView[]>;
|
||||
setDecryptedCiphers: (value: CipherView[], options?: StorageOptions) => Promise<void>;
|
||||
getDecryptedCollections: (options?: StorageOptions) => Promise<CollectionView[]>;
|
||||
@@ -164,8 +165,6 @@ export abstract class StateService<T extends Account = Account> {
|
||||
setEnableAlwaysOnTop: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getEnableAutoFillOnPageLoad: (options?: StorageOptions) => Promise<boolean>;
|
||||
setEnableAutoFillOnPageLoad: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getEnableBiometric: (options?: StorageOptions) => Promise<boolean>;
|
||||
setEnableBiometric: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getEnableBrowserIntegration: (options?: StorageOptions) => Promise<boolean>;
|
||||
setEnableBrowserIntegration: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getEnableBrowserIntegrationFingerprint: (options?: StorageOptions) => Promise<boolean>;
|
||||
@@ -293,8 +292,6 @@ export abstract class StateService<T extends Account = Account> {
|
||||
setMinimizeOnCopyToClipboard: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getNeverDomains: (options?: StorageOptions) => Promise<{ [id: string]: any }>;
|
||||
setNeverDomains: (value: { [id: string]: any }, options?: StorageOptions) => Promise<void>;
|
||||
getNoAutoPromptBiometrics: (options?: StorageOptions) => Promise<boolean>;
|
||||
setNoAutoPromptBiometrics: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getNoAutoPromptBiometricsText: (options?: StorageOptions) => Promise<string>;
|
||||
setNoAutoPromptBiometricsText: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
getOpenAtLogin: (options?: StorageOptions) => Promise<boolean>;
|
||||
|
||||
6
libs/common/src/auth/types/biometric-key.d.ts
vendored
Normal file
6
libs/common/src/auth/types/biometric-key.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
import { CsprngString } from "../../types/csprng";
|
||||
|
||||
export type BiometricKey = {
|
||||
key: string;
|
||||
clientEncKeyHalf: CsprngString;
|
||||
};
|
||||
@@ -7,3 +7,28 @@ export enum EncryptionType {
|
||||
Rsa2048_OaepSha256_HmacSha256_B64 = 5,
|
||||
Rsa2048_OaepSha1_HmacSha256_B64 = 6,
|
||||
}
|
||||
|
||||
/** The expected number of parts to a serialized EncString of the given encryption type.
|
||||
* For example, an EncString of type AesCbc256_B64 will have 2 parts, and an EncString of type
|
||||
* AesCbc128_HmacSha256_B64 will have 3 parts.
|
||||
*
|
||||
* Example of annotated serialized EncStrings:
|
||||
* 0.iv|data
|
||||
* 1.iv|data|mac
|
||||
* 2.iv|data|mac
|
||||
* 3.data
|
||||
* 4.data
|
||||
*
|
||||
* @see EncString
|
||||
* @see EncryptionType
|
||||
* @see EncString.parseEncryptedString
|
||||
*/
|
||||
export const EXPECTED_NUM_PARTS_BY_ENCRYPTION_TYPE = {
|
||||
[EncryptionType.AesCbc256_B64]: 2,
|
||||
[EncryptionType.AesCbc128_HmacSha256_B64]: 3,
|
||||
[EncryptionType.AesCbc256_HmacSha256_B64]: 3,
|
||||
[EncryptionType.Rsa2048_OaepSha256_B64]: 1,
|
||||
[EncryptionType.Rsa2048_OaepSha1_B64]: 1,
|
||||
[EncryptionType.Rsa2048_OaepSha256_HmacSha256_B64]: 2,
|
||||
[EncryptionType.Rsa2048_OaepSha1_HmacSha256_B64]: 2,
|
||||
};
|
||||
|
||||
@@ -5,5 +5,6 @@ export enum StateVersion {
|
||||
Four = 4, // Fix 'Never Lock' option by removing stale data
|
||||
Five = 5, // Migrate to new storage of encrypted organization keys
|
||||
Six = 6, // Delete account.keys.legacyEtmKey property
|
||||
Latest = Six,
|
||||
Seven = 7, // Remove global desktop auto prompt setting, move to account
|
||||
Latest = Seven,
|
||||
}
|
||||
|
||||
@@ -133,27 +133,20 @@ export class AccountKeys {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Object.assign(
|
||||
new AccountKeys(),
|
||||
{ cryptoMasterKey: SymmetricCryptoKey.fromJSON(obj?.cryptoMasterKey) },
|
||||
{
|
||||
cryptoSymmetricKey: EncryptionPair.fromJSON(
|
||||
obj?.cryptoSymmetricKey,
|
||||
SymmetricCryptoKey.fromJSON
|
||||
),
|
||||
},
|
||||
{ organizationKeys: AccountKeys.initRecordEncryptionPairsFromJSON(obj?.organizationKeys) },
|
||||
{ providerKeys: AccountKeys.initRecordEncryptionPairsFromJSON(obj?.providerKeys) },
|
||||
{
|
||||
privateKey: EncryptionPair.fromJSON<string, ArrayBuffer>(
|
||||
obj?.privateKey,
|
||||
(decObj: string) => Utils.fromByteStringToArray(decObj).buffer
|
||||
),
|
||||
},
|
||||
{
|
||||
publicKey: Utils.fromByteStringToArray(obj?.publicKey)?.buffer,
|
||||
}
|
||||
);
|
||||
return Object.assign(new AccountKeys(), {
|
||||
cryptoMasterKey: SymmetricCryptoKey.fromJSON(obj?.cryptoMasterKey),
|
||||
cryptoSymmetricKey: EncryptionPair.fromJSON(
|
||||
obj?.cryptoSymmetricKey,
|
||||
SymmetricCryptoKey.fromJSON
|
||||
),
|
||||
organizationKeys: AccountKeys.initRecordEncryptionPairsFromJSON(obj?.organizationKeys),
|
||||
providerKeys: AccountKeys.initRecordEncryptionPairsFromJSON(obj?.providerKeys),
|
||||
privateKey: EncryptionPair.fromJSON<string, ArrayBuffer>(
|
||||
obj?.privateKey,
|
||||
(decObj: string) => Utils.fromByteStringToArray(decObj).buffer
|
||||
),
|
||||
publicKey: Utils.fromByteStringToArray(obj?.publicKey)?.buffer,
|
||||
});
|
||||
}
|
||||
|
||||
static initRecordEncryptionPairsFromJSON(obj: any) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { EncryptionType } from "../../enums";
|
||||
import { EncryptionType, EXPECTED_NUM_PARTS_BY_ENCRYPTION_TYPE } from "../../enums";
|
||||
import { IEncrypted } from "../../interfaces/IEncrypted";
|
||||
import { Utils } from "../../misc/utils";
|
||||
|
||||
@@ -75,34 +75,26 @@ export class EncString implements IEncrypted {
|
||||
return;
|
||||
}
|
||||
|
||||
const { encType, encPieces } = this.parseEncryptedString(this.encryptedString);
|
||||
const { encType, encPieces } = EncString.parseEncryptedString(this.encryptedString);
|
||||
this.encryptionType = encType;
|
||||
|
||||
if (encPieces.length !== EXPECTED_NUM_PARTS_BY_ENCRYPTION_TYPE[encType]) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (encType) {
|
||||
case EncryptionType.AesCbc128_HmacSha256_B64:
|
||||
case EncryptionType.AesCbc256_HmacSha256_B64:
|
||||
if (encPieces.length !== 3) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.iv = encPieces[0];
|
||||
this.data = encPieces[1];
|
||||
this.mac = encPieces[2];
|
||||
break;
|
||||
case EncryptionType.AesCbc256_B64:
|
||||
if (encPieces.length !== 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.iv = encPieces[0];
|
||||
this.data = encPieces[1];
|
||||
break;
|
||||
case EncryptionType.Rsa2048_OaepSha256_B64:
|
||||
case EncryptionType.Rsa2048_OaepSha1_B64:
|
||||
if (encPieces.length !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.data = encPieces[0];
|
||||
break;
|
||||
default:
|
||||
@@ -110,7 +102,7 @@ export class EncString implements IEncrypted {
|
||||
}
|
||||
}
|
||||
|
||||
private parseEncryptedString(encryptedString: string): {
|
||||
private static parseEncryptedString(encryptedString: string): {
|
||||
encType: EncryptionType;
|
||||
encPieces: string[];
|
||||
} {
|
||||
@@ -139,6 +131,12 @@ export class EncString implements IEncrypted {
|
||||
};
|
||||
}
|
||||
|
||||
static isSerializedEncString(s: string): boolean {
|
||||
const { encType, encPieces } = this.parseEncryptedString(s);
|
||||
|
||||
return EXPECTED_NUM_PARTS_BY_ENCRYPTION_TYPE[encType] === encPieces.length;
|
||||
}
|
||||
|
||||
async decrypt(orgId: string, key: SymmetricCryptoKey = null): Promise<string> {
|
||||
if (this.decryptedValue != null) {
|
||||
return this.decryptedValue;
|
||||
|
||||
@@ -24,7 +24,6 @@ export class GlobalState {
|
||||
mainWindowSize?: number;
|
||||
enableBiometrics?: boolean;
|
||||
biometricText?: string;
|
||||
noAutoPromptBiometrics?: boolean;
|
||||
noAutoPromptBiometricsText?: string;
|
||||
stateVersion: StateVersion = StateVersion.One;
|
||||
environmentUrls: EnvironmentUrls = new EnvironmentUrls();
|
||||
|
||||
@@ -62,12 +62,16 @@ export class SymmetricCryptoKey {
|
||||
return { keyB64: this.keyB64 };
|
||||
}
|
||||
|
||||
static fromJSON(obj: Jsonify<SymmetricCryptoKey>): SymmetricCryptoKey {
|
||||
if (obj == null) {
|
||||
static fromString(s: string): SymmetricCryptoKey {
|
||||
if (s == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const arrayBuffer = Utils.fromB64ToArray(obj.keyB64).buffer;
|
||||
const arrayBuffer = Utils.fromB64ToArray(s).buffer;
|
||||
return new SymmetricCryptoKey(arrayBuffer);
|
||||
}
|
||||
|
||||
static fromJSON(obj: Jsonify<SymmetricCryptoKey>): SymmetricCryptoKey {
|
||||
return SymmetricCryptoKey.fromString(obj?.keyB64);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,8 +30,8 @@ import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key";
|
||||
|
||||
export class CryptoService implements CryptoServiceAbstraction {
|
||||
constructor(
|
||||
private cryptoFunctionService: CryptoFunctionService,
|
||||
private encryptService: EncryptService,
|
||||
protected cryptoFunctionService: CryptoFunctionService,
|
||||
protected encryptService: EncryptService,
|
||||
protected platformUtilService: PlatformUtilsService,
|
||||
protected logService: LogService,
|
||||
protected stateService: StateService
|
||||
@@ -716,16 +716,19 @@ export class CryptoService implements CryptoServiceAbstraction {
|
||||
// ---HELPERS---
|
||||
|
||||
protected async storeKey(key: SymmetricCryptoKey, userId?: string) {
|
||||
if (await this.shouldStoreKey(KeySuffixOptions.Auto, userId)) {
|
||||
await this.stateService.setCryptoMasterKeyAuto(key.keyB64, { userId: userId });
|
||||
} else if (await this.shouldStoreKey(KeySuffixOptions.Biometric, userId)) {
|
||||
await this.stateService.setCryptoMasterKeyBiometric(key.keyB64, { userId: userId });
|
||||
const storeAuto = await this.shouldStoreKey(KeySuffixOptions.Auto, userId);
|
||||
|
||||
if (storeAuto) {
|
||||
await this.storeAutoKey(key, userId);
|
||||
} else {
|
||||
await this.stateService.setCryptoMasterKeyAuto(null, { userId: userId });
|
||||
await this.stateService.setCryptoMasterKeyBiometric(null, { userId: userId });
|
||||
}
|
||||
}
|
||||
|
||||
protected async storeAutoKey(key: SymmetricCryptoKey, userId?: string) {
|
||||
await this.stateService.setCryptoMasterKeyAuto(key.keyB64, { userId: userId });
|
||||
}
|
||||
|
||||
protected async shouldStoreKey(keySuffix: KeySuffixOptions, userId?: string) {
|
||||
let shouldStoreKey = false;
|
||||
if (keySuffix === KeySuffixOptions.Auto) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BehaviorSubject, concatMap } from "rxjs";
|
||||
import { Jsonify } from "type-fest";
|
||||
import { Jsonify, JsonValue } from "type-fest";
|
||||
|
||||
import { LogService } from "../abstractions/log.service";
|
||||
import { StateService as StateServiceAbstraction } from "../abstractions/state.service";
|
||||
@@ -18,6 +18,7 @@ import { CollectionView } from "../admin-console/models/view/collection.view";
|
||||
import { EnvironmentUrls } from "../auth/models/domain/environment-urls";
|
||||
import { ForceResetPasswordReason } from "../auth/models/domain/force-reset-password-reason";
|
||||
import { KdfConfig } from "../auth/models/domain/kdf-config";
|
||||
import { BiometricKey } from "../auth/types/biometric-key";
|
||||
import { HtmlStorageLocation, KdfType, StorageLocation, ThemeType, UriMatchType } from "../enums";
|
||||
import { VaultTimeoutAction } from "../enums/vault-timeout-action.enum";
|
||||
import { StateFactory } from "../factories/stateFactory";
|
||||
@@ -607,7 +608,7 @@ export class StateService<
|
||||
);
|
||||
}
|
||||
|
||||
async setCryptoMasterKeyBiometric(value: string, options?: StorageOptions): Promise<void> {
|
||||
async setCryptoMasterKeyBiometric(value: BiometricKey, options?: StorageOptions): Promise<void> {
|
||||
options = this.reconcileOptions(
|
||||
this.reconcileOptions(options, { keySuffix: "biometric" }),
|
||||
await this.defaultSecureStorageOptions()
|
||||
@@ -1136,24 +1137,6 @@ export class StateService<
|
||||
);
|
||||
}
|
||||
|
||||
async getEnableBiometric(options?: StorageOptions): Promise<boolean> {
|
||||
return (
|
||||
(await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
|
||||
?.enableBiometrics ?? false
|
||||
);
|
||||
}
|
||||
|
||||
async setEnableBiometric(value: boolean, options?: StorageOptions): Promise<void> {
|
||||
const globals = await this.getGlobals(
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions())
|
||||
);
|
||||
globals.enableBiometrics = value;
|
||||
await this.saveGlobals(
|
||||
globals,
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions())
|
||||
);
|
||||
}
|
||||
|
||||
async getEnableBrowserIntegration(options?: StorageOptions): Promise<boolean> {
|
||||
return (
|
||||
(await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
|
||||
@@ -1876,24 +1859,6 @@ export class StateService<
|
||||
);
|
||||
}
|
||||
|
||||
async getNoAutoPromptBiometrics(options?: StorageOptions): Promise<boolean> {
|
||||
return (
|
||||
(await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
|
||||
?.noAutoPromptBiometrics ?? false
|
||||
);
|
||||
}
|
||||
|
||||
async setNoAutoPromptBiometrics(value: boolean, options?: StorageOptions): Promise<void> {
|
||||
const globals = await this.getGlobals(
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions())
|
||||
);
|
||||
globals.noAutoPromptBiometrics = value;
|
||||
await this.saveGlobals(
|
||||
globals,
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions())
|
||||
);
|
||||
}
|
||||
|
||||
async getNoAutoPromptBiometricsText(options?: StorageOptions): Promise<string> {
|
||||
return (
|
||||
await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions()))
|
||||
@@ -2848,7 +2813,11 @@ export class StateService<
|
||||
return this.reconcileOptions(options, defaultOptions);
|
||||
}
|
||||
|
||||
private async saveSecureStorageKey(key: string, value: string, options?: StorageOptions) {
|
||||
private async saveSecureStorageKey<T extends JsonValue>(
|
||||
key: string,
|
||||
value: T,
|
||||
options?: StorageOptions
|
||||
) {
|
||||
return value == null
|
||||
? await this.secureStorageService.remove(`${options.userId}${key}`, options)
|
||||
: await this.secureStorageService.save(`${options.userId}${key}`, value, options);
|
||||
|
||||
@@ -174,6 +174,22 @@ export class StateMigrationService<
|
||||
await this.setCurrentStateVersion(StateVersion.Six);
|
||||
break;
|
||||
}
|
||||
case StateVersion.Six: {
|
||||
const authenticatedAccounts = await this.getAuthenticatedAccounts();
|
||||
const globals = (await this.getGlobals()) as any;
|
||||
for (const account of authenticatedAccounts) {
|
||||
const migratedAccount = await this.migrateAccountFrom6To7(
|
||||
globals?.noAutoPromptBiometrics,
|
||||
account
|
||||
);
|
||||
await this.set(account.profile.userId, migratedAccount);
|
||||
}
|
||||
if (globals) {
|
||||
delete globals.noAutoPromptBiometrics;
|
||||
}
|
||||
await this.set(keys.global, globals);
|
||||
await this.setCurrentStateVersion(StateVersion.Seven);
|
||||
}
|
||||
}
|
||||
|
||||
currentStateVersion += 1;
|
||||
@@ -204,7 +220,7 @@ export class StateMigrationService<
|
||||
// 1. Check for an existing storage value from the old storage structure OR
|
||||
// 2. Check for a value already set by processes that run before migration OR
|
||||
// 3. Assign the default value
|
||||
const globals =
|
||||
const globals: any =
|
||||
(await this.get<GlobalState>(keys.global)) ?? this.stateFactory.createGlobal(null);
|
||||
globals.stateVersion = StateVersion.Two;
|
||||
globals.environmentUrls =
|
||||
@@ -525,6 +541,16 @@ export class StateMigrationService<
|
||||
return account;
|
||||
}
|
||||
|
||||
protected async migrateAccountFrom6To7(
|
||||
globalSetting: boolean,
|
||||
account: TAccount
|
||||
): Promise<TAccount> {
|
||||
if (globalSetting) {
|
||||
account.settings = Object.assign({}, account.settings, { disableAutoBiometricsPrompt: true });
|
||||
}
|
||||
return account;
|
||||
}
|
||||
|
||||
protected get options(): StorageOptions {
|
||||
return { htmlStorageLocation: HtmlStorageLocation.Local };
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { CryptoFunctionService } from "../abstractions/cryptoFunction.service";
|
||||
import { Utils } from "../misc/utils";
|
||||
import { DecryptParameters } from "../models/domain/decrypt-parameters";
|
||||
import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key";
|
||||
import { CsprngArray } from "../types/csprng";
|
||||
|
||||
export class WebCryptoFunctionService implements CryptoFunctionService {
|
||||
private crypto: Crypto;
|
||||
@@ -350,10 +351,10 @@ export class WebCryptoFunctionService implements CryptoFunctionService {
|
||||
return [publicKey, privateKey];
|
||||
}
|
||||
|
||||
randomBytes(length: number): Promise<ArrayBuffer> {
|
||||
randomBytes(length: number): Promise<CsprngArray> {
|
||||
const arr = new Uint8Array(length);
|
||||
this.crypto.getRandomValues(arr);
|
||||
return Promise.resolve(arr.buffer);
|
||||
return Promise.resolve(arr.buffer as CsprngArray);
|
||||
}
|
||||
|
||||
private toBuf(value: string | ArrayBuffer): ArrayBuffer {
|
||||
|
||||
5
libs/common/src/types/csprng.d.ts
vendored
Normal file
5
libs/common/src/types/csprng.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Opaque } from "type-fest";
|
||||
|
||||
type CsprngArray = Opaque<ArrayBuffer, "CSPRNG">;
|
||||
|
||||
type CsprngString = Opaque<string, "CSPRNG">;
|
||||
Reference in New Issue
Block a user