diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index d1266a174e4..66e6e807ad9 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -234,3 +234,4 @@ libs/pricing @bitwarden/team-billing-dev .github/workflows/respond.yml @bitwarden/team-ai-sme .github/workflows/review-code.yml @bitwarden/team-ai-sme libs/subscription @bitwarden/team-billing-dev +libs/unlock @bitwarden/team-key-management-dev diff --git a/jest.config.js b/jest.config.js index bfe447f7a53..e8773d73568 100644 --- a/jest.config.js +++ b/jest.config.js @@ -61,6 +61,7 @@ module.exports = { "/libs/vault/jest.config.js", "/libs/auto-confirm/jest.config.js", "/libs/subscription/jest.config.js", + "/libs/unlock/jest.config.js", ], // Workaround for a memory leak that crashes tests in CI: diff --git a/libs/common/src/key-management/master-password/types/master-password.types.ts b/libs/common/src/key-management/master-password/types/master-password.types.ts index 5ba22905140..a0b6490c4e6 100644 --- a/libs/common/src/key-management/master-password/types/master-password.types.ts +++ b/libs/common/src/key-management/master-password/types/master-password.types.ts @@ -35,7 +35,7 @@ export class MasterPasswordUnlockData { readonly salt: MasterPasswordSalt, readonly kdf: KdfConfig, readonly masterKeyWrappedUserKey: MasterKeyWrappedUserKey, - ) {} + ) { } static fromSdk(sdkData: SdkMasterPasswordUnlockData): MasterPasswordUnlockData { return new MasterPasswordUnlockData( @@ -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, diff --git a/libs/unlock/README.md b/libs/unlock/README.md new file mode 100644 index 00000000000..d597857af0c --- /dev/null +++ b/libs/unlock/README.md @@ -0,0 +1,5 @@ +# unlock + +Owned by: key-management + +Unlock the account of a user diff --git a/libs/unlock/eslint.config.mjs b/libs/unlock/eslint.config.mjs new file mode 100644 index 00000000000..9c37d10e3ff --- /dev/null +++ b/libs/unlock/eslint.config.mjs @@ -0,0 +1,3 @@ +import baseConfig from "../../eslint.config.mjs"; + +export default [...baseConfig]; diff --git a/libs/unlock/jest.config.js b/libs/unlock/jest.config.js new file mode 100644 index 00000000000..a334dda3126 --- /dev/null +++ b/libs/unlock/jest.config.js @@ -0,0 +1,10 @@ +module.exports = { + displayName: "unlock", + preset: "../../jest.preset.js", + testEnvironment: "node", + transform: { + "^.+\\.[tj]s$": ["ts-jest", { tsconfig: "/tsconfig.spec.json" }], + }, + moduleFileExtensions: ["ts", "js", "html"], + coverageDirectory: "../../coverage/libs/unlock", +}; diff --git a/libs/unlock/package.json b/libs/unlock/package.json new file mode 100644 index 00000000000..f98da8683d0 --- /dev/null +++ b/libs/unlock/package.json @@ -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" +} diff --git a/libs/unlock/project.json b/libs/unlock/project.json new file mode 100644 index 00000000000..5bebf6c2609 --- /dev/null +++ b/libs/unlock/project.json @@ -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" + } + } + } +} diff --git a/libs/unlock/src/abstractions/index.ts b/libs/unlock/src/abstractions/index.ts new file mode 100644 index 00000000000..9393b2c8475 --- /dev/null +++ b/libs/unlock/src/abstractions/index.ts @@ -0,0 +1 @@ +export { UnlockService } from "./unlock.service"; diff --git a/libs/unlock/src/abstractions/unlock.service.ts b/libs/unlock/src/abstractions/unlock.service.ts new file mode 100644 index 00000000000..13a07fc8779 --- /dev/null +++ b/libs/unlock/src/abstractions/unlock.service.ts @@ -0,0 +1,19 @@ +import { UserId } from "@bitwarden/common/types/guid"; + +import { PinLockType } from "@bitwarden/common/key-management/pin/pin-lock-type"; + +/** + * 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 + * @param pinLockType - The type of PIN lock (PERSISTENT or EPHEMERAL) + * @throws If the SDK is not available + * @throws If the PIN is invalid or decryption fails + */ + abstract unlockWithPin(userId: UserId, pin: string, pinLockType: PinLockType): Promise; +} diff --git a/libs/unlock/src/index.ts b/libs/unlock/src/index.ts new file mode 100644 index 00000000000..8cddc0545a2 --- /dev/null +++ b/libs/unlock/src/index.ts @@ -0,0 +1,5 @@ +export { UnlockService } from "./abstractions/unlock.service"; +export { DefaultUnlockService } from "./unlock.service"; + +// Re-export abstractions +export * from "./abstractions"; diff --git a/libs/unlock/src/unlock.service.ts b/libs/unlock/src/unlock.service.ts new file mode 100644 index 00000000000..23b178b213f --- /dev/null +++ b/libs/unlock/src/unlock.service.ts @@ -0,0 +1,201 @@ +import { first, firstValueFrom, map } from "rxjs"; + +import { AccountCryptographicStateService } from "@bitwarden/common/key-management/account-cryptography/account-cryptographic-state.service"; +import { PinLockType } from "@bitwarden/common/key-management/pin/pin-lock-type"; +import { PinStateServiceAbstraction } from "@bitwarden/common/key-management/pin/pin-state.service.abstraction"; +import { RegisterSdkService } from "@bitwarden/common/platform/abstractions/sdk/register-sdk.service"; +import { UserId } from "@bitwarden/common/types/guid"; + +import { UnlockService } from "./abstractions/unlock.service"; +import { KdfConfig, KdfConfigService } from "@bitwarden/key-management"; +import { EncString, Kdf, MasterPasswordUnlockData, PasswordProtectedKeyEnvelope, UnsignedSharedKey, WrappedAccountCryptographicState } from "@bitwarden/sdk-internal"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; +import { + asUuid, +} from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { DeviceKey } from "@bitwarden/common/types/key"; + +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 apiService: ApiService, + ) { } + + private async getAccountCryptographicState(userId: UserId): Promise { + return firstValueFrom( + this.accountCryptographicStateService.accountCryptographicState$(userId), + ); + } + + private async getKdfParams(userId: UserId): Promise { + return firstValueFrom( + this.kdfService.getKdfConfig$(userId).pipe( + map((config: KdfConfig) => { + return config.toSdkConfig(); + }), + ), + ); + } + + private async getEmail(userId: UserId): Promise { + return await firstValueFrom( + this.accountService.activeAccount$.pipe(map((a) => a?.email)), + ); + } + + private async getPinProtectedUserKeyEnvelope(userId: UserId): Promise { + const pinLockType = await this.pinStateService.getPinLockType(userId); + return this.pinStateService.getPinProtectedUserKeyEnvelope( + userId, + pinLockType, + ); + } + + private async getMasterPasswordUnlockData(userId: UserId): Promise { + const unlockData = await firstValueFrom(this.masterPasswordService.masterPasswordUnlockData$(userId)); + return unlockData.toSdk(); + } + + async unlockWithDeviceKey(userId: UserId, + encryptedDevicePrivateKey: EncString, + encryptedUserKey: UnsignedSharedKey, + deviceKey: DeviceKey, + ): Promise { + 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: { + deviceKey: { + device_key: deviceKey.toBase64(), + protected_device_private_key: encryptedDevicePrivateKey, + device_protected_user_key: encryptedUserKey, + }, + }, + }); + }), + ), + ); + } + + async unlockWithAuthRequest(userId: UserId, privateKey: string, protectedUserKey: UnsignedSharedKey): Promise { + 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: { + authRequest: { + request_private_key: privateKey, + method: { + userKey: { + protected_user_key: protectedUserKey, + } + } + }, + }, + }); + }), + ), + ); + } + + async unlockWithKeyConnector(userId: UserId, keyConnectorUrl: string): Promise { + const keyConnectorKey = (await this.apiService.getMasterKeyFromKeyConnector(keyConnectorUrl)).key; + const keyConnectorKeyWrappedUserKey = await this.masterPasswordService.getMasterKeyEncryptedUserKey(userId); + 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: { + keyConnector: { + master_key: keyConnectorKey, + user_key: keyConnectorKeyWrappedUserKey.toSdk(), + }, + }, + }); + }), + ), + ); + } + + async unlockWithPin(userId: UserId, pin: string): Promise { + 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), + }, + }, + }); + }), + ), + ); + } + + async unlockWithMasterPassword(userId: UserId, masterPassword: string): Promise { + 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), + }, + }, + }); + }), + ), + ); + } +} diff --git a/libs/unlock/src/unlock.spec.ts b/libs/unlock/src/unlock.spec.ts new file mode 100644 index 00000000000..84ad068209f --- /dev/null +++ b/libs/unlock/src/unlock.spec.ts @@ -0,0 +1,8 @@ +import * as lib from "./index"; + +describe("unlock", () => { + // This test will fail until something is exported from index.ts + it("should work", () => { + expect(lib).toBeDefined(); + }); +}); diff --git a/libs/unlock/tsconfig.eslint.json b/libs/unlock/tsconfig.eslint.json new file mode 100644 index 00000000000..3daf120441a --- /dev/null +++ b/libs/unlock/tsconfig.eslint.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": ["src/**/*.ts", "src/**/*.js"], + "exclude": ["**/build", "**/dist"] +} diff --git a/libs/unlock/tsconfig.json b/libs/unlock/tsconfig.json new file mode 100644 index 00000000000..62ebbd94647 --- /dev/null +++ b/libs/unlock/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/unlock/tsconfig.lib.json b/libs/unlock/tsconfig.lib.json new file mode 100644 index 00000000000..9cbf6736007 --- /dev/null +++ b/libs/unlock/tsconfig.lib.json @@ -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"] +} diff --git a/libs/unlock/tsconfig.spec.json b/libs/unlock/tsconfig.spec.json new file mode 100644 index 00000000000..1275f148a18 --- /dev/null +++ b/libs/unlock/tsconfig.spec.json @@ -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"] +} diff --git a/package-lock.json b/package-lock.json index 95842c6b409..25b445e6f26 100644 --- a/package-lock.json +++ b/package-lock.json @@ -683,6 +683,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", @@ -5143,6 +5148,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 diff --git a/tsconfig.base.json b/tsconfig.base.json index 68498cfae01..a4fb63997d3 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -30,8 +30,8 @@ "@bitwarden/browser/*": ["./apps/browser/src/*"], "@bitwarden/cli/*": ["./apps/cli/src/*"], "@bitwarden/client-type": ["./libs/client-type/src/index.ts"], - "@bitwarden/common/spec": ["./libs/common/spec"], "@bitwarden/common/*": ["./libs/common/src/*"], + "@bitwarden/common/spec": ["./libs/common/spec"], "@bitwarden/components": ["./libs/components/src"], "@bitwarden/core-test-utils": ["./libs/core-test-utils/src/index.ts"], "@bitwarden/dirt-card": ["./libs/dirt/card/src"], @@ -62,6 +62,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/vault": ["./libs/vault/src"], "@bitwarden/vault-export-core": ["./libs/tools/export/vault-export/vault-export-core/src"],