mirror of
https://github.com/bitwarden/browser
synced 2026-02-26 17:43:22 +00:00
[PM-31763] Add unlock service & module (#18870)
* Add unlock service * Move methods * Prettier * Fix type errors * Prettier * Fix test * Fix module order * Attempt to fix tests * Cleanup CODEOWNERS * Backport biometric unlock and legacy master-key logic * Add tests for biometrics * Prettier * Add biometric unlock to abstract unlock service * Fix build
This commit is contained in:
3
.github/CODEOWNERS
vendored
3
.github/CODEOWNERS
vendored
@@ -92,6 +92,7 @@ libs/angular/src/billing @bitwarden/team-billing-dev
|
||||
libs/common/src/billing @bitwarden/team-billing-dev
|
||||
libs/billing @bitwarden/team-billing-dev
|
||||
libs/pricing @bitwarden/team-billing-dev
|
||||
libs/subscription @bitwarden/team-billing-dev
|
||||
bitwarden_license/bit-web/src/app/billing @bitwarden/team-billing-dev
|
||||
|
||||
## Platform team files ##
|
||||
@@ -191,6 +192,7 @@ libs/key-management @bitwarden/team-key-management-dev
|
||||
libs/key-management-ui @bitwarden/team-key-management-dev
|
||||
libs/user-crypto-management @bitwarden/team-key-management-dev
|
||||
libs/common/src/key-management @bitwarden/team-key-management-dev
|
||||
libs/unlock @bitwarden/team-key-management-dev
|
||||
# Node-cryptofunction service
|
||||
libs/node @bitwarden/team-key-management-dev
|
||||
|
||||
@@ -247,4 +249,3 @@ apps/desktop/native-messaging-test-runner/package-lock.json @bitwarden/team-plat
|
||||
.claude/ @bitwarden/team-ai-sme
|
||||
.github/workflows/respond.yml @bitwarden/team-ai-sme
|
||||
.github/workflows/review-code.yml @bitwarden/team-ai-sme
|
||||
libs/subscription @bitwarden/team-billing-dev
|
||||
|
||||
@@ -61,6 +61,7 @@ module.exports = {
|
||||
"<rootDir>/libs/vault/jest.config.js",
|
||||
"<rootDir>/libs/auto-confirm/jest.config.js",
|
||||
"<rootDir>/libs/subscription/jest.config.js",
|
||||
"<rootDir>/libs/unlock/jest.config.js",
|
||||
"<rootDir>/libs/user-crypto-management/jest.config.js",
|
||||
],
|
||||
|
||||
|
||||
@@ -389,6 +389,7 @@ import {
|
||||
DefaultStateService,
|
||||
} from "@bitwarden/state-internal";
|
||||
import { SafeInjectionToken } from "@bitwarden/ui-common";
|
||||
import { DefaultUnlockService, UnlockService } from "@bitwarden/unlock";
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { PasswordRepromptService } from "@bitwarden/vault";
|
||||
@@ -919,6 +920,22 @@ const safeProviders: SafeProvider[] = [
|
||||
useClass: DefaultAccountCryptographicStateService,
|
||||
deps: [StateProvider],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: UnlockService,
|
||||
useClass: DefaultUnlockService,
|
||||
deps: [
|
||||
RegisterSdkService,
|
||||
AccountCryptographicStateService,
|
||||
PinStateServiceAbstraction,
|
||||
KdfConfigService,
|
||||
AccountServiceAbstraction,
|
||||
InternalMasterPasswordServiceAbstraction,
|
||||
CryptoFunctionServiceAbstraction,
|
||||
StateProvider,
|
||||
LogService,
|
||||
BiometricsService,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: BroadcasterService,
|
||||
useClass: DefaultBroadcasterService,
|
||||
|
||||
@@ -45,10 +45,14 @@ export const MASTER_KEY = new UserKeyDefinition<MasterKey>(MASTER_PASSWORD_MEMOR
|
||||
});
|
||||
|
||||
/** Disk since master key hash is used for unlock */
|
||||
const MASTER_KEY_HASH = new UserKeyDefinition<string>(MASTER_PASSWORD_DISK, "masterKeyHash", {
|
||||
deserializer: (masterKeyHash) => masterKeyHash,
|
||||
clearOn: ["logout"],
|
||||
});
|
||||
export const MASTER_KEY_HASH = new UserKeyDefinition<string>(
|
||||
MASTER_PASSWORD_DISK,
|
||||
"masterKeyHash",
|
||||
{
|
||||
deserializer: (masterKeyHash) => masterKeyHash,
|
||||
clearOn: ["logout"],
|
||||
},
|
||||
);
|
||||
|
||||
/** Disk to persist through lock */
|
||||
export const MASTER_KEY_ENCRYPTED_USER_KEY = new UserKeyDefinition<EncryptedString>(
|
||||
|
||||
@@ -45,6 +45,14 @@ export class MasterPasswordUnlockData {
|
||||
);
|
||||
}
|
||||
|
||||
toSdk(): SdkMasterPasswordUnlockData {
|
||||
return {
|
||||
salt: this.salt,
|
||||
kdf: this.kdf.toSdkConfig(),
|
||||
masterKeyWrappedUserKey: this.masterKeyWrappedUserKey,
|
||||
};
|
||||
}
|
||||
|
||||
toJSON(): any {
|
||||
return {
|
||||
salt: this.salt,
|
||||
|
||||
5
libs/unlock/README.md
Normal file
5
libs/unlock/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Unlock
|
||||
|
||||
Owned by: key-management
|
||||
|
||||
Unlock the account of a user using any of their unlock methods.
|
||||
3
libs/unlock/eslint.config.mjs
Normal file
3
libs/unlock/eslint.config.mjs
Normal file
@@ -0,0 +1,3 @@
|
||||
import baseConfig from "../../eslint.config.mjs";
|
||||
|
||||
export default [...baseConfig];
|
||||
10
libs/unlock/jest.config.js
Normal file
10
libs/unlock/jest.config.js
Normal file
@@ -0,0 +1,10 @@
|
||||
module.exports = {
|
||||
displayName: "unlock",
|
||||
preset: "../../jest.preset.js",
|
||||
testEnvironment: "node",
|
||||
transform: {
|
||||
"^.+\\.[tj]s$": ["ts-jest", { tsconfig: "<rootDir>/tsconfig.spec.json" }],
|
||||
},
|
||||
moduleFileExtensions: ["ts", "js", "html"],
|
||||
coverageDirectory: "../../coverage/libs/unlock",
|
||||
};
|
||||
11
libs/unlock/package.json
Normal file
11
libs/unlock/package.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@bitwarden/unlock",
|
||||
"version": "0.0.1",
|
||||
"description": "Unlock the account of a user",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"main": "index.js",
|
||||
"types": "index.d.ts",
|
||||
"license": "GPL-3.0",
|
||||
"author": "key-management"
|
||||
}
|
||||
34
libs/unlock/project.json
Normal file
34
libs/unlock/project.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "unlock",
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/unlock/src",
|
||||
"projectType": "library",
|
||||
"tags": [],
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@nx/js:tsc",
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"options": {
|
||||
"outputPath": "dist/libs/unlock",
|
||||
"main": "libs/unlock/src/index.ts",
|
||||
"tsConfig": "libs/unlock/tsconfig.lib.json",
|
||||
"assets": ["libs/unlock/*.md"],
|
||||
"rootDir": "libs/unlock/src"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint",
|
||||
"outputs": ["{options.outputFile}"],
|
||||
"options": {
|
||||
"lintFilePatterns": ["libs/unlock/**/*.ts"]
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"executor": "@nx/jest:jest",
|
||||
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
|
||||
"options": {
|
||||
"jestConfig": "libs/unlock/jest.config.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
219
libs/unlock/src/default-unlock.service.spec.ts
Normal file
219
libs/unlock/src/default-unlock.service.spec.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
// Polyfill for Symbol.dispose required by the service's use of `using` keyword
|
||||
import "core-js/proposals/explicit-resource-management";
|
||||
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AccountCryptographicStateService } from "@bitwarden/common/key-management/account-cryptography/account-cryptographic-state.service";
|
||||
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import { PinStateServiceAbstraction } from "@bitwarden/common/key-management/pin/pin-state.service.abstraction";
|
||||
import { RegisterSdkService } from "@bitwarden/common/platform/abstractions/sdk/register-sdk.service";
|
||||
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { CsprngArray } from "@bitwarden/common/types/csprng";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { UserKey } from "@bitwarden/common/types/key";
|
||||
import { BiometricsService, KdfConfigService } from "@bitwarden/key-management";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
import { PureCrypto } from "@bitwarden/sdk-internal";
|
||||
import { StateProvider } from "@bitwarden/state";
|
||||
|
||||
import { DefaultUnlockService } from "./default-unlock.service";
|
||||
|
||||
const mockUserId = "b1e2d3c4-a1b2-c3d4-e5f6-a1b2c3d4e5f6" as UserId;
|
||||
const mockEmail = "test@example.com";
|
||||
const mockPin = "1234";
|
||||
const mockMasterPassword = "master-password";
|
||||
const mockKdfParams = { type: "pbkdf2" } as any;
|
||||
const mockAccountCryptographicState = { some: "state" } as any;
|
||||
const mockPinProtectedUserKeyEnvelope = { some: "envelope" } as any;
|
||||
const mockMasterPasswordUnlockData = { some: "unlockData", salt: "salt", kdf: "pbkdf2" } as any;
|
||||
|
||||
describe("DefaultUnlockService", () => {
|
||||
const registerSdkService = mock<RegisterSdkService>();
|
||||
const accountCryptographicStateService = mock<AccountCryptographicStateService>();
|
||||
const pinStateService = mock<PinStateServiceAbstraction>();
|
||||
const kdfService = mock<KdfConfigService>();
|
||||
const accountService = mock<AccountService>();
|
||||
const masterPasswordService = mock<InternalMasterPasswordServiceAbstraction>();
|
||||
const cryptoFunctionService = mock<CryptoFunctionService>();
|
||||
const stateProvider = mock<StateProvider>();
|
||||
const logService = mock<LogService>();
|
||||
const biometricsService = mock<BiometricsService>();
|
||||
|
||||
let service: DefaultUnlockService;
|
||||
let mockSdkRef: any;
|
||||
let mockSdk: any;
|
||||
let mockCrypto: any;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
|
||||
mockCrypto = {
|
||||
initialize_user_crypto: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
mockSdkRef = {
|
||||
value: {
|
||||
crypto: jest.fn().mockReturnValue(mockCrypto),
|
||||
},
|
||||
[Symbol.dispose]: jest.fn(),
|
||||
};
|
||||
|
||||
mockSdk = {
|
||||
take: jest.fn().mockReturnValue(mockSdkRef),
|
||||
};
|
||||
|
||||
registerSdkService.registerClient$.mockReturnValue(of(mockSdk));
|
||||
accountCryptographicStateService.accountCryptographicState$.mockReturnValue(
|
||||
of(mockAccountCryptographicState),
|
||||
);
|
||||
kdfService.getKdfConfig$.mockReturnValue(of({ toSdkConfig: () => mockKdfParams } as any));
|
||||
accountService.accounts$ = of({
|
||||
[mockUserId]: { email: mockEmail },
|
||||
} as any);
|
||||
pinStateService.getPinLockType.mockResolvedValue("PERSISTENT" as any);
|
||||
pinStateService.getPinProtectedUserKeyEnvelope.mockResolvedValue(
|
||||
mockPinProtectedUserKeyEnvelope,
|
||||
);
|
||||
masterPasswordService.masterPasswordUnlockData$.mockReturnValue(
|
||||
of({ toSdk: () => mockMasterPasswordUnlockData } as any),
|
||||
);
|
||||
|
||||
Object.defineProperty(SdkLoadService, "Ready", {
|
||||
value: Promise.resolve(),
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
jest.spyOn(PureCrypto, "derive_kdf_material").mockReturnValue(new Uint8Array(32));
|
||||
|
||||
cryptoFunctionService.pbkdf2.mockResolvedValue(new Uint8Array(32));
|
||||
|
||||
const mockStateUpdate = jest.fn().mockResolvedValue(undefined);
|
||||
stateProvider.getUser.mockReturnValue({ update: mockStateUpdate } as any);
|
||||
|
||||
service = new DefaultUnlockService(
|
||||
registerSdkService,
|
||||
accountCryptographicStateService,
|
||||
pinStateService,
|
||||
kdfService,
|
||||
accountService,
|
||||
masterPasswordService,
|
||||
cryptoFunctionService,
|
||||
stateProvider,
|
||||
logService,
|
||||
biometricsService,
|
||||
);
|
||||
});
|
||||
|
||||
describe("unlockWithPin", () => {
|
||||
it("calls SDK initialize_user_crypto with correct pin method", async () => {
|
||||
await service.unlockWithPin(mockUserId, mockPin);
|
||||
|
||||
expect(mockCrypto.initialize_user_crypto).toHaveBeenCalledWith({
|
||||
userId: mockUserId,
|
||||
kdfParams: mockKdfParams,
|
||||
email: mockEmail,
|
||||
accountCryptographicState: mockAccountCryptographicState,
|
||||
method: {
|
||||
pinEnvelope: {
|
||||
pin: mockPin,
|
||||
pin_protected_user_key_envelope: mockPinProtectedUserKeyEnvelope,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("throws when SDK is not available", async () => {
|
||||
registerSdkService.registerClient$.mockReturnValue(of(null as any));
|
||||
|
||||
await expect(service.unlockWithPin(mockUserId, mockPin)).rejects.toThrow("SDK not available");
|
||||
});
|
||||
|
||||
it("fetches PERSISTENT pin envelope when the pin lock type is persistent", async () => {
|
||||
pinStateService.getPinLockType.mockResolvedValue("PERSISTENT" as any);
|
||||
await service.unlockWithPin(mockUserId, mockPin);
|
||||
expect(pinStateService.getPinProtectedUserKeyEnvelope).toHaveBeenCalledWith(
|
||||
mockUserId,
|
||||
"PERSISTENT",
|
||||
);
|
||||
});
|
||||
|
||||
it("fetches EPHEMERAL pin envelope when the pin lock type is ephemeral", async () => {
|
||||
pinStateService.getPinLockType.mockResolvedValue("EPHEMERAL" as any);
|
||||
await service.unlockWithPin(mockUserId, mockPin);
|
||||
expect(pinStateService.getPinProtectedUserKeyEnvelope).toHaveBeenCalledWith(
|
||||
mockUserId,
|
||||
"EPHEMERAL",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unlockWithMasterPassword", () => {
|
||||
it("calls SDK initialize_user_crypto with correct master password method", async () => {
|
||||
await service.unlockWithMasterPassword(mockUserId, mockMasterPassword);
|
||||
|
||||
expect(mockCrypto.initialize_user_crypto).toHaveBeenCalledWith({
|
||||
userId: mockUserId,
|
||||
kdfParams: mockKdfParams,
|
||||
email: mockEmail,
|
||||
accountCryptographicState: mockAccountCryptographicState,
|
||||
method: {
|
||||
masterPasswordUnlock: {
|
||||
password: mockMasterPassword,
|
||||
master_password_unlock: mockMasterPasswordUnlockData,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("throws when SDK is not available", async () => {
|
||||
registerSdkService.registerClient$.mockReturnValue(of(null as any));
|
||||
|
||||
await expect(
|
||||
service.unlockWithMasterPassword(mockUserId, mockMasterPassword),
|
||||
).rejects.toThrow("SDK not available");
|
||||
});
|
||||
});
|
||||
|
||||
describe("unlockWithBiometrics", () => {
|
||||
const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64) as CsprngArray) as UserKey;
|
||||
|
||||
it("calls SDK initialize_user_crypto with decrypted key from biometrics", async () => {
|
||||
biometricsService.unlockWithBiometricsForUser.mockResolvedValue(mockUserKey);
|
||||
|
||||
await service.unlockWithBiometrics(mockUserId);
|
||||
|
||||
expect(biometricsService.unlockWithBiometricsForUser).toHaveBeenCalledWith(mockUserId);
|
||||
expect(mockCrypto.initialize_user_crypto).toHaveBeenCalledWith({
|
||||
userId: mockUserId,
|
||||
kdfParams: mockKdfParams,
|
||||
email: mockEmail,
|
||||
accountCryptographicState: mockAccountCryptographicState,
|
||||
method: {
|
||||
decryptedKey: {
|
||||
decrypted_user_key: mockUserKey.toBase64(),
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("throws when biometrics returns null", async () => {
|
||||
biometricsService.unlockWithBiometricsForUser.mockResolvedValue(null);
|
||||
|
||||
await expect(service.unlockWithBiometrics(mockUserId)).rejects.toThrow(
|
||||
"Failed to unlock with biometrics",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when SDK is not available", async () => {
|
||||
biometricsService.unlockWithBiometricsForUser.mockResolvedValue(mockUserKey);
|
||||
registerSdkService.registerClient$.mockReturnValue(of(null as any));
|
||||
|
||||
await expect(service.unlockWithBiometrics(mockUserId)).rejects.toThrow("SDK not available");
|
||||
});
|
||||
});
|
||||
});
|
||||
227
libs/unlock/src/default-unlock.service.ts
Normal file
227
libs/unlock/src/default-unlock.service.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import { firstValueFrom, map } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { assertNonNullish } from "@bitwarden/common/auth/utils";
|
||||
import { AccountCryptographicStateService } from "@bitwarden/common/key-management/account-cryptography/account-cryptographic-state.service";
|
||||
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import {
|
||||
MASTER_KEY,
|
||||
MASTER_KEY_HASH,
|
||||
} from "@bitwarden/common/key-management/master-password/services/master-password.service";
|
||||
import { PinStateServiceAbstraction } from "@bitwarden/common/key-management/pin/pin-state.service.abstraction";
|
||||
import { RegisterSdkService } from "@bitwarden/common/platform/abstractions/sdk/register-sdk.service";
|
||||
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
|
||||
import { asUuid } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { MasterKey } from "@bitwarden/common/types/key";
|
||||
import { BiometricsService, KdfConfig, KdfConfigService } from "@bitwarden/key-management";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
import {
|
||||
Kdf,
|
||||
MasterPasswordUnlockData,
|
||||
PasswordProtectedKeyEnvelope,
|
||||
PureCrypto,
|
||||
WrappedAccountCryptographicState,
|
||||
} from "@bitwarden/sdk-internal";
|
||||
import { StateProvider } from "@bitwarden/state";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { UnlockService } from "./unlock.service";
|
||||
|
||||
export class DefaultUnlockService implements UnlockService {
|
||||
constructor(
|
||||
private registerSdkService: RegisterSdkService,
|
||||
private accountCryptographicStateService: AccountCryptographicStateService,
|
||||
private pinStateService: PinStateServiceAbstraction,
|
||||
private kdfService: KdfConfigService,
|
||||
private accountService: AccountService,
|
||||
private masterPasswordService: InternalMasterPasswordServiceAbstraction,
|
||||
private cryptoFunctionService: CryptoFunctionService,
|
||||
private stateProvider: StateProvider,
|
||||
private logService: LogService,
|
||||
private biometricsService: BiometricsService,
|
||||
) {}
|
||||
|
||||
async unlockWithPin(userId: UserId, pin: string): Promise<void> {
|
||||
const startTime = performance.now();
|
||||
await firstValueFrom(
|
||||
this.registerSdkService.registerClient$(userId).pipe(
|
||||
map(async (sdk) => {
|
||||
if (!sdk) {
|
||||
throw new Error("SDK not available");
|
||||
}
|
||||
using ref = sdk.take();
|
||||
return ref.value.crypto().initialize_user_crypto({
|
||||
userId: asUuid(userId),
|
||||
kdfParams: await this.getKdfParams(userId),
|
||||
email: await this.getEmail(userId)!,
|
||||
accountCryptographicState: await this.getAccountCryptographicState(userId),
|
||||
method: {
|
||||
pinEnvelope: {
|
||||
pin: pin,
|
||||
pin_protected_user_key_envelope: await this.getPinProtectedUserKeyEnvelope(userId),
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
),
|
||||
);
|
||||
this.logService.measure(startTime, "Unlock", "DefaultUnlockService", "unlockWithPin");
|
||||
}
|
||||
|
||||
async unlockWithMasterPassword(userId: UserId, masterPassword: string): Promise<void> {
|
||||
const startTime = performance.now();
|
||||
await firstValueFrom(
|
||||
this.registerSdkService.registerClient$(userId).pipe(
|
||||
map(async (sdk) => {
|
||||
if (!sdk) {
|
||||
throw new Error("SDK not available");
|
||||
}
|
||||
using ref = sdk.take();
|
||||
return ref.value.crypto().initialize_user_crypto({
|
||||
userId: asUuid(userId),
|
||||
kdfParams: await this.getKdfParams(userId),
|
||||
email: await this.getEmail(userId),
|
||||
accountCryptographicState: await this.getAccountCryptographicState(userId),
|
||||
method: {
|
||||
masterPasswordUnlock: {
|
||||
password: masterPassword,
|
||||
master_password_unlock: await this.getMasterPasswordUnlockData(userId),
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
),
|
||||
);
|
||||
await this.setLegacyMasterKeyFromUnlockData(
|
||||
masterPassword,
|
||||
await this.getMasterPasswordUnlockData(userId),
|
||||
userId,
|
||||
);
|
||||
this.logService.measure(
|
||||
startTime,
|
||||
"Unlock",
|
||||
"DefaultUnlockService",
|
||||
"unlockWithMasterPassword",
|
||||
);
|
||||
}
|
||||
|
||||
async unlockWithBiometrics(userId: UserId): Promise<void> {
|
||||
// First, get the biometrics-protected user key. This will prompt the user to authenticate with biometrics.
|
||||
const userKey = await this.biometricsService.unlockWithBiometricsForUser(userId);
|
||||
if (!userKey) {
|
||||
throw new Error("Failed to unlock with biometrics");
|
||||
}
|
||||
|
||||
// Now that we have the biometrics-protected user key, we can initialize the SDK with it to complete the unlock process.
|
||||
const startTime = performance.now();
|
||||
await firstValueFrom(
|
||||
this.registerSdkService.registerClient$(userId).pipe(
|
||||
map(async (sdk) => {
|
||||
if (!sdk) {
|
||||
throw new Error("SDK not available");
|
||||
}
|
||||
using ref = sdk.take();
|
||||
return ref.value.crypto().initialize_user_crypto({
|
||||
userId: asUuid(userId),
|
||||
kdfParams: await this.getKdfParams(userId),
|
||||
email: await this.getEmail(userId),
|
||||
accountCryptographicState: await this.getAccountCryptographicState(userId),
|
||||
method: {
|
||||
decryptedKey: {
|
||||
decrypted_user_key: userKey.toBase64(),
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
),
|
||||
);
|
||||
this.logService.measure(startTime, "Unlock", "DefaultUnlockService", "unlockWithBiometrics");
|
||||
}
|
||||
|
||||
private async getAccountCryptographicState(
|
||||
userId: UserId,
|
||||
): Promise<WrappedAccountCryptographicState> {
|
||||
const accountCryptographicState = await firstValueFrom(
|
||||
this.accountCryptographicStateService.accountCryptographicState$(userId),
|
||||
);
|
||||
assertNonNullish(accountCryptographicState, "Account cryptographic state is required");
|
||||
return accountCryptographicState!;
|
||||
}
|
||||
|
||||
private async getKdfParams(userId: UserId): Promise<Kdf> {
|
||||
const kdfParams = await firstValueFrom(
|
||||
this.kdfService.getKdfConfig$(userId).pipe(
|
||||
map((config: KdfConfig | null) => {
|
||||
return config?.toSdkConfig();
|
||||
}),
|
||||
),
|
||||
);
|
||||
assertNonNullish(kdfParams, "KDF parameters are required");
|
||||
return kdfParams!;
|
||||
}
|
||||
|
||||
private async getEmail(userId: UserId): Promise<string> {
|
||||
const accounts = await firstValueFrom(this.accountService.accounts$);
|
||||
const email = accounts[userId].email;
|
||||
assertNonNullish(email, "Email is required");
|
||||
return email;
|
||||
}
|
||||
|
||||
private async getPinProtectedUserKeyEnvelope(
|
||||
userId: UserId,
|
||||
): Promise<PasswordProtectedKeyEnvelope> {
|
||||
const pinLockType = await this.pinStateService.getPinLockType(userId);
|
||||
const pinEnvelope = await this.pinStateService.getPinProtectedUserKeyEnvelope(
|
||||
userId,
|
||||
pinLockType,
|
||||
);
|
||||
assertNonNullish(pinEnvelope, "User is not enrolled in PIN unlock");
|
||||
return pinEnvelope!;
|
||||
}
|
||||
|
||||
private async getMasterPasswordUnlockData(userId: UserId): Promise<MasterPasswordUnlockData> {
|
||||
const unlockData = await firstValueFrom(
|
||||
this.masterPasswordService.masterPasswordUnlockData$(userId),
|
||||
);
|
||||
assertNonNullish(unlockData, "Master password unlock data is required");
|
||||
return unlockData.toSdk();
|
||||
}
|
||||
|
||||
private async setLegacyMasterKeyFromUnlockData(
|
||||
password: string,
|
||||
masterPasswordUnlockData: MasterPasswordUnlockData,
|
||||
userId: UserId,
|
||||
): Promise<void> {
|
||||
assertNonNullish(password, "password");
|
||||
assertNonNullish(masterPasswordUnlockData, "masterPasswordUnlockData");
|
||||
assertNonNullish(userId, "userId");
|
||||
this.logService.info("[DefaultUnlockService] Setting legacy master key from unlock data");
|
||||
|
||||
// NOTE: This entire section is deprecated and will be removed as soon as
|
||||
// the masterkey is dropped from state. It is very temporary.
|
||||
await SdkLoadService.Ready;
|
||||
|
||||
const passwordBuffer = new TextEncoder().encode(password);
|
||||
const saltBuffer = new TextEncoder().encode(masterPasswordUnlockData.salt);
|
||||
const masterKey = PureCrypto.derive_kdf_material(
|
||||
passwordBuffer,
|
||||
saltBuffer,
|
||||
masterPasswordUnlockData.kdf,
|
||||
);
|
||||
const hash = await this.cryptoFunctionService.pbkdf2(
|
||||
masterKey,
|
||||
password,
|
||||
"sha256",
|
||||
2, // HashPurpose.LocalAuthorization
|
||||
);
|
||||
await this.stateProvider
|
||||
.getUser(userId, MASTER_KEY)
|
||||
.update((_) => new SymmetricCryptoKey(masterKey) as MasterKey);
|
||||
await this.stateProvider
|
||||
.getUser(userId, MASTER_KEY_HASH)
|
||||
.update((_) => Utils.fromBufferToB64(hash));
|
||||
}
|
||||
}
|
||||
2
libs/unlock/src/index.ts
Normal file
2
libs/unlock/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { UnlockService } from "./unlock.service";
|
||||
export { DefaultUnlockService } from "./default-unlock.service";
|
||||
35
libs/unlock/src/unlock.service.ts
Normal file
35
libs/unlock/src/unlock.service.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
/**
|
||||
* Service for unlocking a user's account with various methods.
|
||||
*/
|
||||
export abstract class UnlockService {
|
||||
/**
|
||||
* Unlocks the user's account using their PIN.
|
||||
*
|
||||
* @param userId - The user's id
|
||||
* @param pin - The user's PIN
|
||||
* @throws If the SDK is not available
|
||||
* @throws If the PIN is invalid or decryption fails
|
||||
*/
|
||||
abstract unlockWithPin(userId: UserId, pin: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Unlocks the user's account using their master password.
|
||||
*
|
||||
* @param userId - The user's id
|
||||
* @param masterPassword - The user's master password
|
||||
* @throws If the SDK is not available
|
||||
* @throws If the master password is invalid or decryption fails
|
||||
*/
|
||||
abstract unlockWithMasterPassword(userId: UserId, masterPassword: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Unlocks the user's account using biometrics.
|
||||
*
|
||||
* @param userId - The user's id
|
||||
* @throws If the SDK is not available
|
||||
* @throws If biometric authentication fails
|
||||
*/
|
||||
abstract unlockWithBiometrics(userId: UserId): Promise<void>;
|
||||
}
|
||||
6
libs/unlock/tsconfig.eslint.json
Normal file
6
libs/unlock/tsconfig.eslint.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"files": [],
|
||||
"include": ["src/**/*.ts", "src/**/*.js"],
|
||||
"exclude": ["**/build", "**/dist"]
|
||||
}
|
||||
13
libs/unlock/tsconfig.json
Normal file
13
libs/unlock/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
10
libs/unlock/tsconfig.lib.json
Normal file
10
libs/unlock/tsconfig.lib.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["jest.config.js", "src/**/*.spec.ts"]
|
||||
}
|
||||
10
libs/unlock/tsconfig.spec.json
Normal file
10
libs/unlock/tsconfig.spec.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node10",
|
||||
"types": ["jest", "node"]
|
||||
},
|
||||
"include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"]
|
||||
}
|
||||
9
package-lock.json
generated
9
package-lock.json
generated
@@ -637,6 +637,11 @@
|
||||
"version": "0.0.0",
|
||||
"license": "GPL-3.0"
|
||||
},
|
||||
"libs/unlock": {
|
||||
"name": "@bitwarden/unlock",
|
||||
"version": "0.0.1",
|
||||
"license": "GPL-3.0"
|
||||
},
|
||||
"libs/user-core": {
|
||||
"name": "@bitwarden/user-core",
|
||||
"version": "0.0.0",
|
||||
@@ -5108,6 +5113,10 @@
|
||||
"resolved": "libs/ui/common",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@bitwarden/unlock": {
|
||||
"resolved": "libs/unlock",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@bitwarden/user-core": {
|
||||
"resolved": "libs/user-core",
|
||||
"link": true
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
"@bitwarden/subscription": ["./libs/subscription/src/index.ts"],
|
||||
"@bitwarden/ui-common": ["./libs/ui/common/src"],
|
||||
"@bitwarden/ui-common/setup-jest": ["./libs/ui/common/src/setup-jest"],
|
||||
"@bitwarden/unlock": ["./libs/unlock/src/index.ts"],
|
||||
"@bitwarden/user-core": ["./libs/user-core/src/index.ts"],
|
||||
"@bitwarden/user-crypto-management": ["./libs/user-crypto-management/src/index.ts"],
|
||||
"@bitwarden/vault": ["./libs/vault/src"],
|
||||
|
||||
Reference in New Issue
Block a user