1
0
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:
Matt Gibson
2023-04-18 09:09:47 -04:00
committed by GitHub
parent 4852992662
commit 830af7b06d
55 changed files with 2497 additions and 564 deletions

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
import { CsprngString } from "../../types/csprng";
export type BiometricKey = {
key: string;
clientEncKeyHalf: CsprngString;
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

@@ -0,0 +1,5 @@
import { Opaque } from "type-fest";
type CsprngArray = Opaque<ArrayBuffer, "CSPRNG">;
type CsprngString = Opaque<string, "CSPRNG">;