From 7afc45607764fb2b2a8152b0137eb3010e9721d4 Mon Sep 17 00:00:00 2001 From: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com> Date: Wed, 15 Oct 2025 15:41:10 -0500 Subject: [PATCH] [PM-23246] CLI unlock with masterPasswordUnlockData (#16217) * unlock with masterPasswordUnlockData in the CLI --- apps/cli/src/base-program.ts | 4 +- .../commands/unlock.command.spec.ts | 318 ++++++++++++++++++ .../commands/unlock.command.ts | 76 +++-- apps/cli/src/oss-serve-configurator.ts | 4 +- apps/cli/src/program.ts | 4 +- .../service-container/service-container.ts | 8 + 6 files changed, 387 insertions(+), 27 deletions(-) create mode 100644 apps/cli/src/key-management/commands/unlock.command.spec.ts rename apps/cli/src/{auth => key-management}/commands/unlock.command.ts (69%) diff --git a/apps/cli/src/base-program.ts b/apps/cli/src/base-program.ts index 5957f08de8..69a5e4e1bd 100644 --- a/apps/cli/src/base-program.ts +++ b/apps/cli/src/base-program.ts @@ -7,7 +7,7 @@ import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authenticatio import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { UserId } from "@bitwarden/common/types/guid"; -import { UnlockCommand } from "./auth/commands/unlock.command"; +import { UnlockCommand } from "./key-management/commands/unlock.command"; import { Response } from "./models/response"; import { ListResponse } from "./models/response/list.response"; import { MessageResponse } from "./models/response/message.response"; @@ -182,6 +182,8 @@ export abstract class BaseProgram { this.serviceContainer.organizationApiService, this.serviceContainer.logout, this.serviceContainer.i18nService, + this.serviceContainer.masterPasswordUnlockService, + this.serviceContainer.configService, ); const response = await command.run(null, null); if (!response.success) { diff --git a/apps/cli/src/key-management/commands/unlock.command.spec.ts b/apps/cli/src/key-management/commands/unlock.command.spec.ts new file mode 100644 index 0000000000..928a750dca --- /dev/null +++ b/apps/cli/src/key-management/commands/unlock.command.spec.ts @@ -0,0 +1,318 @@ +import { mock } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; +import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; +import { VerificationType } from "@bitwarden/common/auth/enums/verification-type"; +import { MasterPasswordVerificationResponse } from "@bitwarden/common/auth/types/verification"; +import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; +import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service"; +import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { CsprngArray } from "@bitwarden/common/types/csprng"; +import { MasterKey, UserKey } from "@bitwarden/common/types/key"; +import { KeyService } from "@bitwarden/key-management"; +import { ConsoleLogService } from "@bitwarden/logging"; +import { UserId } from "@bitwarden/user-core"; + +import { MessageResponse } from "../../models/response/message.response"; +import { I18nService } from "../../platform/services/i18n.service"; +import { ConvertToKeyConnectorCommand } from "../convert-to-key-connector.command"; + +import { UnlockCommand } from "./unlock.command"; + +describe("UnlockCommand", () => { + let command: UnlockCommand; + + const accountService = mock(); + const masterPasswordService = mock(); + const keyService = mock(); + const userVerificationService = mock(); + const cryptoFunctionService = mock(); + const logService = mock(); + const keyConnectorService = mock(); + const environmentService = mock(); + const organizationApiService = mock(); + const logout = jest.fn(); + const i18nService = mock(); + const masterPasswordUnlockService = mock(); + const configService = mock(); + + const mockMasterPassword = "testExample"; + const activeAccount: Account = { + id: "user-id" as UserId, + email: "user@example.com", + emailVerified: true, + name: "User", + }; + const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey; + const mockSessionKey = new Uint8Array(64) as CsprngArray; + const b64sessionKey = Utils.fromBufferToB64(mockSessionKey); + const expectedSuccessMessage = new MessageResponse( + "Your vault is now unlocked!", + "\n" + + "To unlock your vault, set your session key to the `BW_SESSION` environment variable. ex:\n" + + '$ export BW_SESSION="' + + b64sessionKey + + '"\n' + + '> $env:BW_SESSION="' + + b64sessionKey + + '"\n\n' + + "You can also pass the session key to any command with the `--session` option. ex:\n" + + "$ bw list items --session " + + b64sessionKey, + ); + expectedSuccessMessage.raw = b64sessionKey; + + // Legacy test data + const mockMasterKey = new SymmetricCryptoKey(new Uint8Array(32)) as MasterKey; + + beforeEach(async () => { + jest.clearAllMocks(); + + i18nService.t.mockImplementation((key: string) => key); + accountService.activeAccount$ = of(activeAccount); + keyConnectorService.convertAccountRequired$ = of(false); + cryptoFunctionService.randomBytes.mockResolvedValue(mockSessionKey); + + command = new UnlockCommand( + accountService, + masterPasswordService, + keyService, + userVerificationService, + cryptoFunctionService, + logService, + keyConnectorService, + environmentService, + organizationApiService, + logout, + i18nService, + masterPasswordUnlockService, + configService, + ); + }); + + describe("run", () => { + test.each([null as unknown as Account, undefined as unknown as Account])( + "returns error response when the active account is %s", + async (account) => { + accountService.activeAccount$ = of(account); + + const response = await command.run(mockMasterPassword, {}); + + expect(response).not.toBeNull(); + expect(response.success).toEqual(false); + expect(response.message).toEqual("No active account found"); + expect(keyService.setUserKey).not.toHaveBeenCalled(); + }, + ); + + test.each([null as unknown as string, undefined as unknown as string, ""])( + "returns error response when the provided password is %s", + async (mockMasterPassword) => { + process.env.BW_NOINTERACTION = "true"; + + const response = await command.run(mockMasterPassword, {}); + + expect(response).not.toBeNull(); + expect(response.success).toEqual(false); + expect(response.message).toEqual( + "Master password is required. Try again in interactive mode or provide a password file or environment variable.", + ); + expect(keyService.setUserKey).not.toHaveBeenCalled(); + }, + ); + + describe("UnlockWithMasterPasswordUnlockData feature flag enabled", () => { + beforeEach(() => { + configService.getFeatureFlag$.mockReturnValue(of(true)); + }); + + it("calls masterPasswordUnlockService successfully", async () => { + masterPasswordUnlockService.unlockWithMasterPassword.mockResolvedValue(mockUserKey); + + const response = await command.run(mockMasterPassword, {}); + + expect(response).not.toBeNull(); + expect(response.success).toEqual(true); + expect(response.data).toEqual(expectedSuccessMessage); + expect(masterPasswordUnlockService.unlockWithMasterPassword).toHaveBeenCalledWith( + mockMasterPassword, + activeAccount.id, + ); + expect(keyService.setUserKey).toHaveBeenCalledWith(mockUserKey, activeAccount.id); + }); + + it("returns error response if unlockWithMasterPassword fails", async () => { + masterPasswordUnlockService.unlockWithMasterPassword.mockRejectedValue( + new Error("Unlock failed"), + ); + + const response = await command.run(mockMasterPassword, {}); + + expect(response).not.toBeNull(); + expect(response.success).toEqual(false); + expect(response.message).toEqual("Unlock failed"); + expect(masterPasswordUnlockService.unlockWithMasterPassword).toHaveBeenCalledWith( + mockMasterPassword, + activeAccount.id, + ); + expect(keyService.setUserKey).not.toHaveBeenCalled(); + }); + }); + + describe("unlock with feature flag off", () => { + beforeEach(() => { + configService.getFeatureFlag$.mockReturnValue(of(false)); + }); + + it("calls decryptUserKeyWithMasterKey successfully", async () => { + userVerificationService.verifyUserByMasterPassword.mockResolvedValue({ + masterKey: mockMasterKey, + } as MasterPasswordVerificationResponse); + masterPasswordService.decryptUserKeyWithMasterKey.mockResolvedValue(mockUserKey); + + const response = await command.run(mockMasterPassword, {}); + + expect(response).not.toBeNull(); + expect(response.success).toEqual(true); + expect(response.data).toEqual(expectedSuccessMessage); + expect(userVerificationService.verifyUserByMasterPassword).toHaveBeenCalledWith( + { + type: VerificationType.MasterPassword, + secret: mockMasterPassword, + }, + activeAccount.id, + activeAccount.email, + ); + expect(masterPasswordService.decryptUserKeyWithMasterKey).toHaveBeenCalledWith( + mockMasterKey, + activeAccount.id, + ); + expect(keyService.setUserKey).toHaveBeenCalledWith(mockUserKey, activeAccount.id); + }); + + it("returns error response when verifyUserByMasterPassword throws", async () => { + userVerificationService.verifyUserByMasterPassword.mockRejectedValue( + new Error("Verification failed"), + ); + + const response = await command.run(mockMasterPassword, {}); + + expect(response).not.toBeNull(); + expect(response.success).toEqual(false); + expect(response.message).toEqual("Verification failed"); + expect(userVerificationService.verifyUserByMasterPassword).toHaveBeenCalledWith( + { + type: VerificationType.MasterPassword, + secret: mockMasterPassword, + }, + activeAccount.id, + activeAccount.email, + ); + expect(masterPasswordService.decryptUserKeyWithMasterKey).not.toHaveBeenCalled(); + expect(keyService.setUserKey).not.toHaveBeenCalled(); + }); + }); + + describe("calls convertToKeyConnectorCommand if required", () => { + let convertToKeyConnectorSpy: jest.SpyInstance; + beforeEach(() => { + keyConnectorService.convertAccountRequired$ = of(true); + + // Feature flag on + masterPasswordUnlockService.unlockWithMasterPassword.mockResolvedValue(mockUserKey); + + // Feature flag off + userVerificationService.verifyUserByMasterPassword.mockResolvedValue({ + masterKey: mockMasterKey, + } as MasterPasswordVerificationResponse); + masterPasswordService.decryptUserKeyWithMasterKey.mockResolvedValue(mockUserKey); + }); + + test.each([true, false])("returns failure when feature flag is %s", async (flagValue) => { + configService.getFeatureFlag$.mockReturnValue(of(flagValue)); + + // Mock the ConvertToKeyConnectorCommand + const mockRun = jest.fn().mockResolvedValue({ success: false, message: "convert failed" }); + convertToKeyConnectorSpy = jest + .spyOn(ConvertToKeyConnectorCommand.prototype, "run") + .mockImplementation(mockRun); + + const response = await command.run(mockMasterPassword, {}); + + expect(response).not.toBeNull(); + expect(response.success).toEqual(false); + expect(response.message).toEqual("convert failed"); + expect(keyService.setUserKey).toHaveBeenCalledWith(mockUserKey, activeAccount.id); + expect(convertToKeyConnectorSpy).toHaveBeenCalled(); + + if (flagValue === true) { + expect(masterPasswordUnlockService.unlockWithMasterPassword).toHaveBeenCalledWith( + mockMasterPassword, + activeAccount.id, + ); + } else { + expect(userVerificationService.verifyUserByMasterPassword).toHaveBeenCalledWith( + { + type: VerificationType.MasterPassword, + secret: mockMasterPassword, + }, + activeAccount.id, + activeAccount.email, + ); + expect(masterPasswordService.decryptUserKeyWithMasterKey).toHaveBeenCalledWith( + mockMasterKey, + activeAccount.id, + ); + } + }); + + test.each([true, false])( + "returns expected success when feature flag is %s", + async (flagValue) => { + configService.getFeatureFlag$.mockReturnValue(of(flagValue)); + + // Mock the ConvertToKeyConnectorCommand + const mockRun = jest.fn().mockResolvedValue({ success: true }); + const convertToKeyConnectorSpy = jest + .spyOn(ConvertToKeyConnectorCommand.prototype, "run") + .mockImplementation(mockRun); + + const response = await command.run(mockMasterPassword, {}); + + expect(response).not.toBeNull(); + expect(response.success).toEqual(true); + expect(response.data).toEqual(expectedSuccessMessage); + expect(keyService.setUserKey).toHaveBeenCalledWith(mockUserKey, activeAccount.id); + expect(convertToKeyConnectorSpy).toHaveBeenCalled(); + + if (flagValue === true) { + expect(masterPasswordUnlockService.unlockWithMasterPassword).toHaveBeenCalledWith( + mockMasterPassword, + activeAccount.id, + ); + } else { + expect(userVerificationService.verifyUserByMasterPassword).toHaveBeenCalledWith( + { + type: VerificationType.MasterPassword, + secret: mockMasterPassword, + }, + activeAccount.id, + activeAccount.email, + ); + expect(masterPasswordService.decryptUserKeyWithMasterKey).toHaveBeenCalledWith( + mockMasterKey, + activeAccount.id, + ); + } + }, + ); + }); + }); +}); diff --git a/apps/cli/src/auth/commands/unlock.command.ts b/apps/cli/src/key-management/commands/unlock.command.ts similarity index 69% rename from apps/cli/src/auth/commands/unlock.command.ts rename to apps/cli/src/key-management/commands/unlock.command.ts index 812a89ed88..4ae8ce823a 100644 --- a/apps/cli/src/auth/commands/unlock.command.ts +++ b/apps/cli/src/key-management/commands/unlock.command.ts @@ -1,26 +1,29 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { firstValueFrom, map } from "rxjs"; +import { firstValueFrom } from "rxjs"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { VerificationType } from "@bitwarden/common/auth/enums/verification-type"; import { MasterPasswordVerification } from "@bitwarden/common/auth/types/verification"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service"; +import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service"; import { MasterKey } from "@bitwarden/common/types/key"; import { KeyService } from "@bitwarden/key-management"; -import { ConvertToKeyConnectorCommand } from "../../key-management/convert-to-key-connector.command"; import { Response } from "../../models/response"; import { MessageResponse } from "../../models/response/message.response"; import { I18nService } from "../../platform/services/i18n.service"; import { CliUtils } from "../../utils"; +import { ConvertToKeyConnectorCommand } from "../convert-to-key-connector.command"; export class UnlockCommand { constructor( @@ -35,6 +38,8 @@ export class UnlockCommand { private organizationApiService: OrganizationApiServiceAbstraction, private logout: () => Promise, private i18nService: I18nService, + private masterPasswordUnlockService: MasterPasswordUnlockService, + private configService: ConfigService, ) {} async run(password: string, cmdOptions: Record) { @@ -48,30 +53,53 @@ export class UnlockCommand { } await this.setNewSessionKey(); - const [userId, email] = await firstValueFrom( - this.accountService.activeAccount$.pipe(map((a) => [a?.id, a?.email])), - ); - - const verification = { - type: VerificationType.MasterPassword, - secret: password, - } as MasterPasswordVerification; - - let masterKey: MasterKey; - try { - const response = await this.userVerificationService.verifyUserByMasterPassword( - verification, - userId, - email, - ); - masterKey = response.masterKey; - } catch (e) { - // verification failure throws - return Response.error(e.message); + const activeAccount = await firstValueFrom(this.accountService.activeAccount$); + if (activeAccount == null) { + return Response.error("No active account found"); } + const userId = activeAccount.id; - const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(masterKey, userId); - await this.keyService.setUserKey(userKey, userId); + if ( + await firstValueFrom( + this.configService.getFeatureFlag$(FeatureFlag.UnlockWithMasterPasswordUnlockData), + ) + ) { + try { + const userKey = await this.masterPasswordUnlockService.unlockWithMasterPassword( + password, + userId, + ); + + await this.keyService.setUserKey(userKey, userId); + } catch (e) { + return Response.error(e.message); + } + } else { + const email = activeAccount.email; + const verification = { + type: VerificationType.MasterPassword, + secret: password, + } as MasterPasswordVerification; + + let masterKey: MasterKey; + try { + const response = await this.userVerificationService.verifyUserByMasterPassword( + verification, + userId, + email, + ); + masterKey = response.masterKey; + } catch (e) { + // verification failure throws + return Response.error(e.message); + } + + const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey( + masterKey, + userId, + ); + await this.keyService.setUserKey(userKey, userId); + } if (await firstValueFrom(this.keyConnectorService.convertAccountRequired$)) { const convertToKeyConnectorCommand = new ConvertToKeyConnectorCommand( diff --git a/apps/cli/src/oss-serve-configurator.ts b/apps/cli/src/oss-serve-configurator.ts index ccc2f3705b..d318a44c67 100644 --- a/apps/cli/src/oss-serve-configurator.ts +++ b/apps/cli/src/oss-serve-configurator.ts @@ -10,12 +10,12 @@ import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfirmCommand } from "./admin-console/commands/confirm.command"; import { ShareCommand } from "./admin-console/commands/share.command"; import { LockCommand } from "./auth/commands/lock.command"; -import { UnlockCommand } from "./auth/commands/unlock.command"; import { EditCommand } from "./commands/edit.command"; import { GetCommand } from "./commands/get.command"; import { ListCommand } from "./commands/list.command"; import { RestoreCommand } from "./commands/restore.command"; import { StatusCommand } from "./commands/status.command"; +import { UnlockCommand } from "./key-management/commands/unlock.command"; import { Response } from "./models/response"; import { FileResponse } from "./models/response/file.response"; import { ServiceContainer } from "./service-container/service-container"; @@ -173,6 +173,8 @@ export class OssServeConfigurator { this.serviceContainer.organizationApiService, async () => await this.serviceContainer.logout(), this.serviceContainer.i18nService, + this.serviceContainer.masterPasswordUnlockService, + this.serviceContainer.configService, ); this.sendCreateCommand = new SendCreateCommand( diff --git a/apps/cli/src/program.ts b/apps/cli/src/program.ts index e8c9bff746..41368269fa 100644 --- a/apps/cli/src/program.ts +++ b/apps/cli/src/program.ts @@ -10,12 +10,12 @@ import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { LockCommand } from "./auth/commands/lock.command"; import { LoginCommand } from "./auth/commands/login.command"; import { LogoutCommand } from "./auth/commands/logout.command"; -import { UnlockCommand } from "./auth/commands/unlock.command"; import { BaseProgram } from "./base-program"; import { CompletionCommand } from "./commands/completion.command"; import { EncodeCommand } from "./commands/encode.command"; import { StatusCommand } from "./commands/status.command"; import { UpdateCommand } from "./commands/update.command"; +import { UnlockCommand } from "./key-management/commands/unlock.command"; import { Response } from "./models/response"; import { MessageResponse } from "./models/response/message.response"; import { ConfigCommand } from "./platform/commands/config.command"; @@ -303,6 +303,8 @@ export class Program extends BaseProgram { this.serviceContainer.organizationApiService, async () => await this.serviceContainer.logout(), this.serviceContainer.i18nService, + this.serviceContainer.masterPasswordUnlockService, + this.serviceContainer.configService, ); const response = await command.run(password, cmd); this.processResponse(response); diff --git a/apps/cli/src/service-container/service-container.ts b/apps/cli/src/service-container/service-container.ts index 3122c3bb9c..c677e705ec 100644 --- a/apps/cli/src/service-container/service-container.ts +++ b/apps/cli/src/service-container/service-container.ts @@ -70,7 +70,9 @@ import { EncryptServiceImplementation } from "@bitwarden/common/key-management/c import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction"; import { DeviceTrustService } from "@bitwarden/common/key-management/device-trust/services/device-trust.service.implementation"; import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/services/key-connector.service"; +import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; +import { DefaultMasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/services/default-master-password-unlock.service"; import { MasterPasswordService } from "@bitwarden/common/key-management/master-password/services/master-password.service"; import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { PinService } from "@bitwarden/common/key-management/pin/pin.service.implementation"; @@ -310,6 +312,7 @@ export class ServiceContainer { restrictedItemTypesService: RestrictedItemTypesService; cliRestrictedItemTypesService: CliRestrictedItemTypesService; securityStateService: SecurityStateService; + masterPasswordUnlockService: MasterPasswordUnlockService; cipherArchiveService: CipherArchiveService; constructor() { @@ -480,6 +483,11 @@ export class ServiceContainer { this.kdfConfigService, ); + this.masterPasswordUnlockService = new DefaultMasterPasswordUnlockService( + this.masterPasswordService, + this.keyService, + ); + this.appIdService = new AppIdService(this.storageService, this.logService); const customUserAgent =