mirror of
https://github.com/bitwarden/browser
synced 2026-02-12 22:44:11 +00:00
[PM-23108] CLI Add Email Verification to Send Receive (#18649)
This commit is contained in:
@@ -39,6 +39,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
|
||||
import { AvatarService as AvatarServiceAbstraction } from "@bitwarden/common/auth/abstractions/avatar.service";
|
||||
import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices-api.service.abstraction";
|
||||
import { MasterPasswordApiService as MasterPasswordApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
|
||||
import { SendTokenService, DefaultSendTokenService } from "@bitwarden/common/auth/send-access";
|
||||
import {
|
||||
AccountServiceImplementation,
|
||||
getUserId,
|
||||
@@ -91,6 +92,8 @@ import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.
|
||||
import { PinService } from "@bitwarden/common/key-management/pin/pin.service.implementation";
|
||||
import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service";
|
||||
import { DefaultSecurityStateService } from "@bitwarden/common/key-management/security-state/services/security-state.service";
|
||||
import { SendPasswordService } from "@bitwarden/common/key-management/sends/abstractions/send-password.service";
|
||||
import { DefaultSendPasswordService } from "@bitwarden/common/key-management/sends/services/default-send-password.service";
|
||||
import {
|
||||
DefaultVaultTimeoutService,
|
||||
DefaultVaultTimeoutSettingsService,
|
||||
@@ -306,6 +309,8 @@ export class ServiceContainer {
|
||||
userVerificationApiService: UserVerificationApiService;
|
||||
organizationApiService: OrganizationApiServiceAbstraction;
|
||||
sendApiService: SendApiService;
|
||||
sendTokenService: SendTokenService;
|
||||
sendPasswordService: SendPasswordService;
|
||||
devicesApiService: DevicesApiServiceAbstraction;
|
||||
deviceTrustService: DeviceTrustServiceAbstraction;
|
||||
authRequestService: AuthRequestService;
|
||||
@@ -629,6 +634,8 @@ export class ServiceContainer {
|
||||
this.sendService,
|
||||
);
|
||||
|
||||
this.sendPasswordService = new DefaultSendPasswordService(this.cryptoFunctionService);
|
||||
|
||||
this.searchService = new SearchService(this.logService, this.i18nService, this.stateProvider);
|
||||
|
||||
this.collectionService = new DefaultCollectionService(
|
||||
@@ -675,6 +682,12 @@ export class ServiceContainer {
|
||||
customUserAgent,
|
||||
);
|
||||
|
||||
this.sendTokenService = new DefaultSendTokenService(
|
||||
this.globalStateProvider,
|
||||
this.sdkService,
|
||||
this.sendPasswordService,
|
||||
);
|
||||
|
||||
this.keyConnectorService = new KeyConnectorService(
|
||||
this.accountService,
|
||||
this.masterPasswordService,
|
||||
|
||||
560
apps/cli/src/tools/send/commands/receive.command.spec.ts
Normal file
560
apps/cli/src/tools/send/commands/receive.command.spec.ts
Normal file
@@ -0,0 +1,560 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { SendTokenService, SendAccessToken } from "@bitwarden/common/auth/send-access";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { SendAccessResponse } from "@bitwarden/common/tools/send/models/response/send-access.response";
|
||||
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
|
||||
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { Response } from "../../../models/response";
|
||||
|
||||
import { SendReceiveCommand } from "./receive.command";
|
||||
|
||||
describe("SendReceiveCommand", () => {
|
||||
let command: SendReceiveCommand;
|
||||
|
||||
const keyService = mock<KeyService>();
|
||||
const encryptService = mock<EncryptService>();
|
||||
const cryptoFunctionService = mock<CryptoFunctionService>();
|
||||
const platformUtilsService = mock<PlatformUtilsService>();
|
||||
const environmentService = mock<EnvironmentService>();
|
||||
const sendApiService = mock<SendApiService>();
|
||||
const apiService = mock<ApiService>();
|
||||
const sendTokenService = mock<SendTokenService>();
|
||||
const configService = mock<ConfigService>();
|
||||
|
||||
const testUrl = "https://send.bitwarden.com/#/send/abc123/key456";
|
||||
const testSendId = "abc123";
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
environmentService.environment$ = of({
|
||||
getUrls: () => ({
|
||||
api: "https://api.bitwarden.com",
|
||||
webVault: "https://vault.bitwarden.com",
|
||||
}),
|
||||
} as any);
|
||||
|
||||
platformUtilsService.isDev.mockReturnValue(false);
|
||||
|
||||
keyService.makeSendKey.mockResolvedValue({} as any);
|
||||
|
||||
cryptoFunctionService.pbkdf2.mockResolvedValue(new Uint8Array(32));
|
||||
|
||||
command = new SendReceiveCommand(
|
||||
keyService,
|
||||
encryptService,
|
||||
cryptoFunctionService,
|
||||
platformUtilsService,
|
||||
environmentService,
|
||||
sendApiService,
|
||||
apiService,
|
||||
sendTokenService,
|
||||
configService,
|
||||
);
|
||||
});
|
||||
|
||||
describe("URL parsing", () => {
|
||||
it("should return error for invalid URL", async () => {
|
||||
const response = await command.run("not-a-valid-url", {});
|
||||
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.message).toContain("Failed to parse");
|
||||
});
|
||||
|
||||
it("should return error when URL is missing send ID or key", async () => {
|
||||
configService.getFeatureFlag.mockResolvedValue(false);
|
||||
|
||||
const response = await command.run("https://send.bitwarden.com/#/send/", {});
|
||||
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.message).toContain("not a valid Send url");
|
||||
});
|
||||
});
|
||||
|
||||
describe("V1 Flow (Feature Flag Off)", () => {
|
||||
beforeEach(() => {
|
||||
configService.getFeatureFlag.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
it("should successfully access unprotected Send", async () => {
|
||||
const mockSendAccess = {
|
||||
id: testSendId,
|
||||
type: SendType.Text,
|
||||
text: { text: "secret message" },
|
||||
};
|
||||
|
||||
sendApiService.postSendAccess.mockResolvedValue({} as any);
|
||||
|
||||
jest.spyOn(command as any, "sendRequest").mockResolvedValue(mockSendAccess);
|
||||
|
||||
const response = await command.run(testUrl, {});
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should successfully access password-protected Send with --password option", async () => {
|
||||
const mockSendAccess = {
|
||||
id: testSendId,
|
||||
type: SendType.Text,
|
||||
text: { text: "secret message" },
|
||||
};
|
||||
|
||||
sendApiService.postSendAccess.mockResolvedValue({} as any);
|
||||
jest.spyOn(command as any, "sendRequest").mockResolvedValue(mockSendAccess);
|
||||
|
||||
const response = await command.run(testUrl, { password: "test-password" });
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(cryptoFunctionService.pbkdf2).toHaveBeenCalledWith(
|
||||
"test-password",
|
||||
expect.any(Uint8Array),
|
||||
"sha256",
|
||||
100000,
|
||||
);
|
||||
});
|
||||
|
||||
it("should return error for incorrect password in non-interactive mode", async () => {
|
||||
process.env.BW_NOINTERACTION = "true";
|
||||
|
||||
const error = new ErrorResponse(
|
||||
{
|
||||
statusCode: 401,
|
||||
message: "Unauthorized",
|
||||
},
|
||||
401,
|
||||
);
|
||||
|
||||
sendApiService.postSendAccess.mockRejectedValue(error);
|
||||
|
||||
const response = await command.run(testUrl, { password: "wrong-password" });
|
||||
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.message).toContain("Incorrect or missing password");
|
||||
|
||||
delete process.env.BW_NOINTERACTION;
|
||||
});
|
||||
|
||||
it("should return 404 for non-existent Send", async () => {
|
||||
const error = new ErrorResponse(
|
||||
{
|
||||
statusCode: 404,
|
||||
message: "Not found",
|
||||
},
|
||||
404,
|
||||
);
|
||||
|
||||
sendApiService.postSendAccess.mockRejectedValue(error);
|
||||
|
||||
const response = await command.run(testUrl, {});
|
||||
|
||||
expect(response.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("V2 Flow (Feature Flag On)", () => {
|
||||
beforeEach(() => {
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
describe("Unprotected Sends", () => {
|
||||
it("should successfully access Send with cached token", async () => {
|
||||
const mockToken = new SendAccessToken("test-token", Date.now() + 3600000);
|
||||
sendTokenService.tryGetSendAccessToken$.mockReturnValue(of(mockToken));
|
||||
sendApiService.postSendAccessV2.mockResolvedValue({} as any);
|
||||
jest.spyOn(command as any, "accessSendWithToken").mockResolvedValue(Response.success());
|
||||
|
||||
const response = await command.run(testUrl, {});
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(sendTokenService.tryGetSendAccessToken$).toHaveBeenCalledWith(testSendId);
|
||||
});
|
||||
|
||||
it("should handle expired token and determine auth type", async () => {
|
||||
sendTokenService.tryGetSendAccessToken$.mockReturnValue(
|
||||
of({
|
||||
kind: "expected_server",
|
||||
error: {
|
||||
error: "invalid_request",
|
||||
send_access_error_type: "password_hash_b64_required",
|
||||
},
|
||||
} as any),
|
||||
);
|
||||
|
||||
// Mock password auth flow
|
||||
const mockToken = new SendAccessToken("test-token", Date.now() + 3600000);
|
||||
sendTokenService.getSendAccessToken$.mockReturnValue(of(mockToken));
|
||||
jest.spyOn(command as any, "accessSendWithToken").mockResolvedValue(Response.success());
|
||||
|
||||
const response = await command.run(testUrl, { password: "test-password" });
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Password Authentication (V2)", () => {
|
||||
it("should successfully authenticate with password", async () => {
|
||||
sendTokenService.tryGetSendAccessToken$.mockReturnValue(
|
||||
of({
|
||||
kind: "expected_server",
|
||||
error: {
|
||||
error: "invalid_request",
|
||||
send_access_error_type: "password_hash_b64_required",
|
||||
},
|
||||
} as any),
|
||||
);
|
||||
|
||||
const mockToken = new SendAccessToken("test-token", Date.now() + 3600000);
|
||||
sendTokenService.getSendAccessToken$.mockReturnValue(of(mockToken));
|
||||
sendApiService.postSendAccessV2.mockResolvedValue({} as any);
|
||||
jest.spyOn(command as any, "accessSendWithToken").mockResolvedValue(Response.success());
|
||||
|
||||
const response = await command.run(testUrl, { password: "correct-password" });
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(sendTokenService.getSendAccessToken$).toHaveBeenCalledWith(
|
||||
testSendId,
|
||||
expect.objectContaining({
|
||||
kind: "password",
|
||||
passwordHashB64: expect.any(String),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should return error for invalid password", async () => {
|
||||
process.env.BW_NOINTERACTION = "true";
|
||||
|
||||
sendTokenService.tryGetSendAccessToken$.mockReturnValue(
|
||||
of({
|
||||
kind: "expected_server",
|
||||
error: {
|
||||
error: "invalid_request",
|
||||
send_access_error_type: "password_hash_b64_required",
|
||||
},
|
||||
} as any),
|
||||
);
|
||||
|
||||
sendTokenService.getSendAccessToken$.mockReturnValue(
|
||||
of({
|
||||
kind: "expected_server",
|
||||
error: {
|
||||
error: "invalid_grant",
|
||||
send_access_error_type: "password_hash_b64_invalid",
|
||||
},
|
||||
} as any),
|
||||
);
|
||||
|
||||
const response = await command.run(testUrl, { password: "wrong-password" });
|
||||
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.message).toContain("Invalid password");
|
||||
|
||||
delete process.env.BW_NOINTERACTION;
|
||||
});
|
||||
|
||||
it("should work with --passwordenv option", async () => {
|
||||
process.env.TEST_SEND_PASSWORD = "env-password";
|
||||
process.env.BW_NOINTERACTION = "true";
|
||||
|
||||
sendTokenService.tryGetSendAccessToken$.mockReturnValue(
|
||||
of({
|
||||
kind: "expected_server",
|
||||
error: {
|
||||
error: "invalid_request",
|
||||
send_access_error_type: "password_hash_b64_required",
|
||||
},
|
||||
} as any),
|
||||
);
|
||||
|
||||
const mockToken = new SendAccessToken("test-token", Date.now() + 3600000);
|
||||
sendTokenService.getSendAccessToken$.mockReturnValue(of(mockToken));
|
||||
jest.spyOn(command as any, "accessSendWithToken").mockResolvedValue(Response.success());
|
||||
|
||||
const response = await command.run(testUrl, { passwordenv: "TEST_SEND_PASSWORD" });
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
|
||||
delete process.env.TEST_SEND_PASSWORD;
|
||||
delete process.env.BW_NOINTERACTION;
|
||||
});
|
||||
});
|
||||
|
||||
describe("Email OTP Authentication (V2)", () => {
|
||||
it("should return error in non-interactive mode for email OTP", async () => {
|
||||
process.env.BW_NOINTERACTION = "true";
|
||||
|
||||
sendTokenService.tryGetSendAccessToken$.mockReturnValue(
|
||||
of({
|
||||
kind: "expected_server",
|
||||
error: {
|
||||
error: "invalid_request",
|
||||
send_access_error_type: "email_required",
|
||||
},
|
||||
} as any),
|
||||
);
|
||||
|
||||
const response = await command.run(testUrl, {});
|
||||
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.message).toContain("Email verification required");
|
||||
expect(response.message).toContain("interactive mode");
|
||||
|
||||
delete process.env.BW_NOINTERACTION;
|
||||
});
|
||||
|
||||
it("should handle email submission and OTP prompt flow", async () => {
|
||||
sendTokenService.tryGetSendAccessToken$.mockReturnValue(
|
||||
of({
|
||||
kind: "expected_server",
|
||||
error: {
|
||||
error: "invalid_request",
|
||||
send_access_error_type: "email_required",
|
||||
},
|
||||
} as any),
|
||||
);
|
||||
|
||||
sendTokenService.getSendAccessToken$.mockReturnValueOnce(
|
||||
of({
|
||||
kind: "expected_server",
|
||||
error: {
|
||||
error: "invalid_request",
|
||||
send_access_error_type: "email_and_otp_required_otp_sent",
|
||||
},
|
||||
} as any),
|
||||
);
|
||||
|
||||
const mockToken = new SendAccessToken("test-token", Date.now() + 3600000);
|
||||
sendTokenService.getSendAccessToken$.mockReturnValueOnce(of(mockToken));
|
||||
|
||||
// We can't easily test the interactive prompts, but we can verify the token service calls
|
||||
// would be made in the right order
|
||||
expect(sendTokenService.getSendAccessToken$).toBeDefined();
|
||||
});
|
||||
|
||||
it("should handle invalid email error", async () => {
|
||||
sendTokenService.tryGetSendAccessToken$.mockReturnValue(
|
||||
of({
|
||||
kind: "expected_server",
|
||||
error: {
|
||||
error: "invalid_request",
|
||||
send_access_error_type: "email_required",
|
||||
},
|
||||
} as any),
|
||||
);
|
||||
|
||||
sendTokenService.getSendAccessToken$.mockReturnValue(
|
||||
of({
|
||||
kind: "expected_server",
|
||||
error: {
|
||||
error: "invalid_grant",
|
||||
send_access_error_type: "email_invalid",
|
||||
},
|
||||
} as any),
|
||||
);
|
||||
|
||||
// In a real scenario with interactive prompts, this would retry
|
||||
// For unit tests, we verify the error is recognized
|
||||
expect(sendTokenService.getSendAccessToken$).toBeDefined();
|
||||
});
|
||||
|
||||
it("should handle invalid OTP error", async () => {
|
||||
sendTokenService.getSendAccessToken$.mockReturnValue(
|
||||
of({
|
||||
kind: "expected_server",
|
||||
error: {
|
||||
error: "invalid_grant",
|
||||
send_access_error_type: "otp_invalid",
|
||||
},
|
||||
} as any),
|
||||
);
|
||||
|
||||
// Verify OTP validation would be handled
|
||||
expect(sendTokenService.getSendAccessToken$).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("File Downloads (V2)", () => {
|
||||
it("should successfully download file Send with V2 API", async () => {
|
||||
const mockToken = new SendAccessToken("test-token", Date.now() + 3600000);
|
||||
sendTokenService.tryGetSendAccessToken$.mockReturnValue(of(mockToken));
|
||||
|
||||
const mockSendResponse = {
|
||||
id: testSendId,
|
||||
type: SendType.File,
|
||||
file: {
|
||||
id: "file-123",
|
||||
fileName: "test.pdf",
|
||||
size: 1024,
|
||||
},
|
||||
};
|
||||
|
||||
sendApiService.postSendAccessV2.mockResolvedValue(mockSendResponse as any);
|
||||
sendApiService.getSendFileDownloadDataV2.mockResolvedValue({
|
||||
url: "https://example.com/download",
|
||||
} as any);
|
||||
|
||||
encryptService.decryptFileData.mockResolvedValue(new ArrayBuffer(1024) as any);
|
||||
jest.spyOn(command as any, "saveAttachmentToFile").mockResolvedValue(Response.success());
|
||||
|
||||
await command.run(testUrl, { output: "./test.pdf" });
|
||||
|
||||
expect(sendApiService.getSendFileDownloadDataV2).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
mockToken,
|
||||
"https://api.bitwarden.com",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Invalid Send ID", () => {
|
||||
it("should return 404 for invalid Send ID", async () => {
|
||||
sendTokenService.tryGetSendAccessToken$.mockReturnValue(
|
||||
of({
|
||||
kind: "expected_server",
|
||||
error: {
|
||||
error: "invalid_grant",
|
||||
send_access_error_type: "send_id_invalid",
|
||||
},
|
||||
} as any),
|
||||
);
|
||||
|
||||
const response = await command.run(testUrl, {});
|
||||
|
||||
expect(response.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Text Send Output", () => {
|
||||
it("should output text to stdout for text Sends", async () => {
|
||||
const mockToken = new SendAccessToken("test-token", Date.now() + 3600000);
|
||||
sendTokenService.tryGetSendAccessToken$.mockReturnValue(of(mockToken));
|
||||
|
||||
const secretText = "This is a secret message";
|
||||
|
||||
sendApiService.postSendAccessV2.mockResolvedValue({} as any);
|
||||
|
||||
// Mock the entire accessSendWithToken to avoid encryption issues
|
||||
jest.spyOn(command as any, "accessSendWithToken").mockImplementation(async () => {
|
||||
process.stdout.write(secretText);
|
||||
return Response.success();
|
||||
});
|
||||
|
||||
const stdoutSpy = jest.spyOn(process.stdout, "write").mockImplementation(() => true);
|
||||
|
||||
const response = await command.run(testUrl, {});
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(stdoutSpy).toHaveBeenCalledWith(secretText);
|
||||
|
||||
stdoutSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should return JSON object when --obj flag is used", async () => {
|
||||
const mockToken = new SendAccessToken("test-token", Date.now() + 3600000);
|
||||
sendTokenService.tryGetSendAccessToken$.mockReturnValue(of(mockToken));
|
||||
|
||||
const mockDecryptedView = {
|
||||
id: testSendId,
|
||||
type: SendType.Text,
|
||||
text: { text: "secret message" },
|
||||
};
|
||||
|
||||
sendApiService.postSendAccessV2.mockResolvedValue({} as any);
|
||||
|
||||
// Mock the entire accessSendWithToken to avoid encryption issues
|
||||
jest.spyOn(command as any, "accessSendWithToken").mockImplementation(async () => {
|
||||
const sendAccessResponse = new SendAccessResponse(mockDecryptedView as any);
|
||||
const res = new Response();
|
||||
res.success = true;
|
||||
res.data = sendAccessResponse as any;
|
||||
return res;
|
||||
});
|
||||
|
||||
const response = await command.run(testUrl, { obj: true });
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(response.data).toBeDefined();
|
||||
expect(response.data.constructor.name).toBe("SendAccessResponse");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("API URL Resolution", () => {
|
||||
it("should resolve send.bitwarden.com to api.bitwarden.com", async () => {
|
||||
configService.getFeatureFlag.mockResolvedValue(false);
|
||||
|
||||
const sendUrl = "https://send.bitwarden.com/#/send/abc123/key456";
|
||||
sendApiService.postSendAccess.mockResolvedValue({} as any);
|
||||
jest.spyOn(command as any, "sendRequest").mockResolvedValue({
|
||||
type: SendType.Text,
|
||||
text: { text: "test" },
|
||||
});
|
||||
|
||||
await command.run(sendUrl, {});
|
||||
|
||||
const apiUrl = await (command as any).getApiUrl(new URL(sendUrl));
|
||||
expect(apiUrl).toBe("https://api.bitwarden.com");
|
||||
});
|
||||
|
||||
it("should handle custom domain URLs", async () => {
|
||||
configService.getFeatureFlag.mockResolvedValue(false);
|
||||
|
||||
const customUrl = "https://custom.example.com/#/send/abc123/key456";
|
||||
sendApiService.postSendAccess.mockResolvedValue({} as any);
|
||||
jest.spyOn(command as any, "sendRequest").mockResolvedValue({
|
||||
type: SendType.Text,
|
||||
text: { text: "test" },
|
||||
});
|
||||
|
||||
await command.run(customUrl, {});
|
||||
|
||||
const apiUrl = await (command as any).getApiUrl(new URL(customUrl));
|
||||
expect(apiUrl).toBe("https://custom.example.com/api");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Feature Flag Routing", () => {
|
||||
it("should route to V1 flow when feature flag is off", async () => {
|
||||
configService.getFeatureFlag.mockResolvedValue(false);
|
||||
|
||||
sendApiService.postSendAccess.mockResolvedValue({} as any);
|
||||
const v1Spy = jest.spyOn(command as any, "attemptV1Access");
|
||||
jest.spyOn(command as any, "sendRequest").mockResolvedValue({
|
||||
type: SendType.Text,
|
||||
text: { text: "test" },
|
||||
});
|
||||
|
||||
await command.run(testUrl, {});
|
||||
|
||||
expect(configService.getFeatureFlag).toHaveBeenCalledWith(FeatureFlag.SendEmailOTP);
|
||||
expect(v1Spy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should route to V2 flow when feature flag is on", async () => {
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
|
||||
const mockToken = new SendAccessToken("test-token", Date.now() + 3600000);
|
||||
sendTokenService.tryGetSendAccessToken$.mockReturnValue(of(mockToken));
|
||||
|
||||
const v2Spy = jest.spyOn(command as any, "attemptV2Access");
|
||||
jest.spyOn(command as any, "accessSendWithToken").mockResolvedValue(Response.success());
|
||||
|
||||
await command.run(testUrl, {});
|
||||
|
||||
expect(configService.getFeatureFlag).toHaveBeenCalledWith(FeatureFlag.SendEmailOTP);
|
||||
expect(v2Spy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,9 +5,25 @@ import * as inquirer from "inquirer";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import {
|
||||
SendTokenService,
|
||||
SendAccessToken,
|
||||
emailRequired,
|
||||
emailAndOtpRequired,
|
||||
otpInvalid,
|
||||
passwordHashB64Required,
|
||||
passwordHashB64Invalid,
|
||||
sendIdInvalid,
|
||||
SendHashedPasswordB64,
|
||||
SendOtp,
|
||||
GetSendAccessTokenError,
|
||||
SendAccessDomainCredentials,
|
||||
} from "@bitwarden/common/auth/send-access";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
@@ -17,6 +33,7 @@ import { SendAccess } from "@bitwarden/common/tools/send/models/domain/send-acce
|
||||
import { SendAccessRequest } from "@bitwarden/common/tools/send/models/request/send-access.request";
|
||||
import { SendAccessView } from "@bitwarden/common/tools/send/models/view/send-access.view";
|
||||
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
|
||||
import { AuthType } from "@bitwarden/common/tools/send/types/auth-type";
|
||||
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
import { NodeUtils } from "@bitwarden/node/node-utils";
|
||||
@@ -38,6 +55,8 @@ export class SendReceiveCommand extends DownloadCommand {
|
||||
private environmentService: EnvironmentService,
|
||||
private sendApiService: SendApiService,
|
||||
apiService: ApiService,
|
||||
private sendTokenService: SendTokenService,
|
||||
private configService: ConfigService,
|
||||
) {
|
||||
super(encryptService, apiService);
|
||||
}
|
||||
@@ -62,58 +81,13 @@ export class SendReceiveCommand extends DownloadCommand {
|
||||
}
|
||||
|
||||
const keyArray = Utils.fromUrlB64ToArray(key);
|
||||
this.sendAccessRequest = new SendAccessRequest();
|
||||
|
||||
let password = options.password;
|
||||
if (password == null || password === "") {
|
||||
if (options.passwordfile) {
|
||||
password = await NodeUtils.readFirstLine(options.passwordfile);
|
||||
} else if (options.passwordenv && process.env[options.passwordenv]) {
|
||||
password = process.env[options.passwordenv];
|
||||
}
|
||||
}
|
||||
const sendEmailOtpEnabled = await this.configService.getFeatureFlag(FeatureFlag.SendEmailOTP);
|
||||
|
||||
if (password != null && password !== "") {
|
||||
this.sendAccessRequest.password = await this.getUnlockedPassword(password, keyArray);
|
||||
}
|
||||
|
||||
const response = await this.sendRequest(apiUrl, id, keyArray);
|
||||
|
||||
if (response instanceof Response) {
|
||||
// Error scenario
|
||||
return response;
|
||||
}
|
||||
|
||||
if (options.obj != null) {
|
||||
return Response.success(new SendAccessResponse(response));
|
||||
}
|
||||
|
||||
switch (response.type) {
|
||||
case SendType.Text:
|
||||
// Write to stdout and response success so we get the text string only to stdout
|
||||
process.stdout.write(response?.text?.text);
|
||||
return Response.success();
|
||||
case SendType.File: {
|
||||
const downloadData = await this.sendApiService.getSendFileDownloadData(
|
||||
response,
|
||||
this.sendAccessRequest,
|
||||
apiUrl,
|
||||
);
|
||||
|
||||
const decryptBufferFn = async (resp: globalThis.Response) => {
|
||||
const encBuf = await EncArrayBuffer.fromResponse(resp);
|
||||
return this.encryptService.decryptFileData(encBuf, this.decKey);
|
||||
};
|
||||
|
||||
return await this.saveAttachmentToFile(
|
||||
downloadData.url,
|
||||
response?.file?.fileName,
|
||||
decryptBufferFn,
|
||||
options.output,
|
||||
);
|
||||
}
|
||||
default:
|
||||
return Response.success(new SendAccessResponse(response));
|
||||
if (sendEmailOtpEnabled) {
|
||||
return await this.attemptV2Access(apiUrl, id, keyArray, options);
|
||||
} else {
|
||||
return await this.attemptV1Access(apiUrl, id, keyArray, options);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,6 +120,350 @@ export class SendReceiveCommand extends DownloadCommand {
|
||||
return Utils.fromBufferToB64(passwordHash);
|
||||
}
|
||||
|
||||
private async attemptV1Access(
|
||||
apiUrl: string,
|
||||
id: string,
|
||||
keyArray: Uint8Array,
|
||||
options: OptionValues,
|
||||
): Promise<Response> {
|
||||
this.sendAccessRequest = new SendAccessRequest();
|
||||
|
||||
let password = options.password;
|
||||
if (password == null || password === "") {
|
||||
if (options.passwordfile) {
|
||||
password = await NodeUtils.readFirstLine(options.passwordfile);
|
||||
} else if (options.passwordenv && process.env[options.passwordenv]) {
|
||||
password = process.env[options.passwordenv];
|
||||
}
|
||||
}
|
||||
|
||||
if (password != null && password !== "") {
|
||||
this.sendAccessRequest.password = await this.getUnlockedPassword(password, keyArray);
|
||||
}
|
||||
|
||||
const response = await this.sendRequest(apiUrl, id, keyArray);
|
||||
|
||||
if (response instanceof Response) {
|
||||
return response;
|
||||
}
|
||||
|
||||
if (options.obj != null) {
|
||||
return Response.success(new SendAccessResponse(response));
|
||||
}
|
||||
|
||||
switch (response.type) {
|
||||
case SendType.Text:
|
||||
process.stdout.write(response?.text?.text);
|
||||
return Response.success();
|
||||
case SendType.File: {
|
||||
const downloadData = await this.sendApiService.getSendFileDownloadData(
|
||||
response,
|
||||
this.sendAccessRequest,
|
||||
apiUrl,
|
||||
);
|
||||
|
||||
const decryptBufferFn = async (resp: globalThis.Response) => {
|
||||
const encBuf = await EncArrayBuffer.fromResponse(resp);
|
||||
return this.encryptService.decryptFileData(encBuf, this.decKey);
|
||||
};
|
||||
|
||||
return await this.saveAttachmentToFile(
|
||||
downloadData.url,
|
||||
response?.file?.fileName,
|
||||
decryptBufferFn,
|
||||
options.output,
|
||||
);
|
||||
}
|
||||
default:
|
||||
return Response.success(new SendAccessResponse(response));
|
||||
}
|
||||
}
|
||||
|
||||
private async attemptV2Access(
|
||||
apiUrl: string,
|
||||
id: string,
|
||||
keyArray: Uint8Array,
|
||||
options: OptionValues,
|
||||
): Promise<Response> {
|
||||
let authType: AuthType = AuthType.None;
|
||||
|
||||
const currentResponse = await this.getTokenWithRetry(id);
|
||||
|
||||
if (currentResponse instanceof SendAccessToken) {
|
||||
return await this.accessSendWithToken(currentResponse, keyArray, apiUrl, options);
|
||||
}
|
||||
|
||||
if (currentResponse.kind === "expected_server") {
|
||||
const error = currentResponse.error;
|
||||
|
||||
if (emailRequired(error)) {
|
||||
authType = AuthType.Email;
|
||||
} else if (passwordHashB64Required(error)) {
|
||||
authType = AuthType.Password;
|
||||
} else if (sendIdInvalid(error)) {
|
||||
return Response.notFound();
|
||||
}
|
||||
} else {
|
||||
return this.handleError(currentResponse);
|
||||
}
|
||||
|
||||
// Handle authentication based on type
|
||||
if (authType === AuthType.Email) {
|
||||
if (!this.canInteract) {
|
||||
return Response.badRequest("Email verification required. Run in interactive mode.");
|
||||
}
|
||||
return await this.handleEmailOtpAuth(id, keyArray, apiUrl, options);
|
||||
} else if (authType === AuthType.Password) {
|
||||
return await this.handlePasswordAuth(id, keyArray, apiUrl, options);
|
||||
}
|
||||
|
||||
// The auth layer will immediately return a token for Sends with AuthType.None
|
||||
// If this code is reached, something has gone wrong
|
||||
if (authType === AuthType.None) {
|
||||
return Response.error("Could not determine authentication requirements");
|
||||
}
|
||||
|
||||
return Response.error("Authentication failed");
|
||||
}
|
||||
|
||||
private async getTokenWithRetry(
|
||||
sendId: string,
|
||||
credentials?: SendAccessDomainCredentials,
|
||||
): Promise<SendAccessToken | GetSendAccessTokenError> {
|
||||
let expiredAttempts = 0;
|
||||
|
||||
while (expiredAttempts < 3) {
|
||||
const response = credentials
|
||||
? await firstValueFrom(this.sendTokenService.getSendAccessToken$(sendId, credentials))
|
||||
: await firstValueFrom(this.sendTokenService.tryGetSendAccessToken$(sendId));
|
||||
|
||||
if (response instanceof SendAccessToken) {
|
||||
return response;
|
||||
}
|
||||
|
||||
if (response.kind === "expired") {
|
||||
expiredAttempts++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Not expired, return the response for caller to handle
|
||||
return response;
|
||||
}
|
||||
|
||||
// After 3 expired attempts, return an error response
|
||||
return {
|
||||
kind: "unknown",
|
||||
error: "Send access token has expired and could not be refreshed",
|
||||
};
|
||||
}
|
||||
|
||||
private handleError(error: GetSendAccessTokenError): Response {
|
||||
if (error.kind === "unexpected_server") {
|
||||
return Response.error("Server error: " + JSON.stringify(error.error));
|
||||
}
|
||||
|
||||
return Response.error("Error: " + JSON.stringify(error.error));
|
||||
}
|
||||
|
||||
private async promptForOtp(sendId: string, email: string): Promise<SendOtp> {
|
||||
const otpAnswer = await inquirer.createPromptModule({ output: process.stderr })({
|
||||
type: "input",
|
||||
name: "otp",
|
||||
message: "Enter the verification code sent to your email:",
|
||||
});
|
||||
return otpAnswer.otp;
|
||||
}
|
||||
|
||||
private async promptForEmail(): Promise<string> {
|
||||
const emailAnswer = await inquirer.createPromptModule({ output: process.stderr })({
|
||||
type: "input",
|
||||
name: "email",
|
||||
message: "Enter your email address:",
|
||||
validate: (input: string) => {
|
||||
if (!input || !input.includes("@")) {
|
||||
return "Please enter a valid email address";
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
return emailAnswer.email;
|
||||
}
|
||||
|
||||
private async handleEmailOtpAuth(
|
||||
sendId: string,
|
||||
keyArray: Uint8Array,
|
||||
apiUrl: string,
|
||||
options: OptionValues,
|
||||
): Promise<Response> {
|
||||
const email = await this.promptForEmail();
|
||||
|
||||
const emailResponse = await this.getTokenWithRetry(sendId, {
|
||||
kind: "email",
|
||||
email: email,
|
||||
});
|
||||
|
||||
if (emailResponse instanceof SendAccessToken) {
|
||||
/*
|
||||
At this point emailResponse should only be expected to be a GetSendAccessTokenError type,
|
||||
but TS must have a logical branch in case it is a SendAccessToken type. If a valid token is
|
||||
returned by the method above, something has gone wrong.
|
||||
*/
|
||||
|
||||
return Response.error("Unexpected server response");
|
||||
}
|
||||
|
||||
if (emailResponse.kind === "expected_server") {
|
||||
const error = emailResponse.error;
|
||||
|
||||
if (emailAndOtpRequired(error)) {
|
||||
const promptResponse = await this.promptForOtp(sendId, email);
|
||||
|
||||
// Use retry helper for expired token handling
|
||||
const otpResponse = await this.getTokenWithRetry(sendId, {
|
||||
kind: "email_otp",
|
||||
email: email,
|
||||
otp: promptResponse,
|
||||
});
|
||||
|
||||
if (otpResponse instanceof SendAccessToken) {
|
||||
return await this.accessSendWithToken(otpResponse, keyArray, apiUrl, options);
|
||||
}
|
||||
|
||||
if (otpResponse.kind === "expected_server") {
|
||||
const error = otpResponse.error;
|
||||
|
||||
if (otpInvalid(error)) {
|
||||
return Response.badRequest("Invalid email or verification code");
|
||||
}
|
||||
|
||||
/*
|
||||
If the following evaluates to true, it means that the email address provided was not
|
||||
configured to be used for email OTP for this Send.
|
||||
|
||||
To avoid leaking information that would allow email enumeration, instead return an
|
||||
error indicating that some component of the email OTP challenge was invalid.
|
||||
*/
|
||||
if (emailAndOtpRequired(error)) {
|
||||
return Response.badRequest("Invalid email or verification code");
|
||||
}
|
||||
}
|
||||
return this.handleError(otpResponse);
|
||||
}
|
||||
}
|
||||
return this.handleError(emailResponse);
|
||||
}
|
||||
|
||||
private async handlePasswordAuth(
|
||||
sendId: string,
|
||||
keyArray: Uint8Array,
|
||||
apiUrl: string,
|
||||
options: OptionValues,
|
||||
): Promise<Response> {
|
||||
let password = options.password;
|
||||
|
||||
if (password == null || password === "") {
|
||||
if (options.passwordfile) {
|
||||
password = await NodeUtils.readFirstLine(options.passwordfile);
|
||||
} else if (options.passwordenv && process.env[options.passwordenv]) {
|
||||
password = process.env[options.passwordenv];
|
||||
}
|
||||
}
|
||||
|
||||
if ((password == null || password === "") && this.canInteract) {
|
||||
const answer = await inquirer.createPromptModule({ output: process.stderr })({
|
||||
type: "password",
|
||||
name: "password",
|
||||
message: "Send password:",
|
||||
});
|
||||
password = answer.password;
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
return Response.badRequest("Password required");
|
||||
}
|
||||
|
||||
const passwordHashB64 = await this.getUnlockedPassword(password, keyArray);
|
||||
|
||||
// Use retry helper for expired token handling
|
||||
const response = await this.getTokenWithRetry(sendId, {
|
||||
kind: "password",
|
||||
passwordHashB64: passwordHashB64 as SendHashedPasswordB64,
|
||||
});
|
||||
|
||||
if (response instanceof SendAccessToken) {
|
||||
return await this.accessSendWithToken(response, keyArray, apiUrl, options);
|
||||
}
|
||||
|
||||
if (response.kind === "expected_server") {
|
||||
const error = response.error;
|
||||
|
||||
if (passwordHashB64Invalid(error)) {
|
||||
return Response.badRequest("Invalid password");
|
||||
}
|
||||
} else if (response.kind === "unexpected_server") {
|
||||
return Response.error("Server error: " + JSON.stringify(response.error));
|
||||
} else if (response.kind === "unknown") {
|
||||
return Response.error("Error: " + response.error);
|
||||
}
|
||||
|
||||
return Response.error("Authentication failed");
|
||||
}
|
||||
|
||||
private async accessSendWithToken(
|
||||
accessToken: SendAccessToken,
|
||||
keyArray: Uint8Array,
|
||||
apiUrl: string,
|
||||
options: OptionValues,
|
||||
): Promise<Response> {
|
||||
try {
|
||||
const sendResponse = await this.sendApiService.postSendAccessV2(accessToken, apiUrl);
|
||||
|
||||
const sendAccess = new SendAccess(sendResponse);
|
||||
this.decKey = await this.keyService.makeSendKey(keyArray);
|
||||
const decryptedView = await sendAccess.decrypt(this.decKey);
|
||||
|
||||
if (options.obj != null) {
|
||||
return Response.success(new SendAccessResponse(decryptedView));
|
||||
}
|
||||
|
||||
switch (decryptedView.type) {
|
||||
case SendType.Text:
|
||||
process.stdout.write(decryptedView?.text?.text);
|
||||
return Response.success();
|
||||
|
||||
case SendType.File: {
|
||||
const downloadData = await this.sendApiService.getSendFileDownloadDataV2(
|
||||
decryptedView,
|
||||
accessToken,
|
||||
apiUrl,
|
||||
);
|
||||
|
||||
const decryptBufferFn = async (resp: globalThis.Response) => {
|
||||
const encBuf = await EncArrayBuffer.fromResponse(resp);
|
||||
return this.encryptService.decryptFileData(encBuf, this.decKey);
|
||||
};
|
||||
|
||||
return await this.saveAttachmentToFile(
|
||||
downloadData.url,
|
||||
decryptedView?.file?.fileName,
|
||||
decryptBufferFn,
|
||||
options.output,
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return Response.success(new SendAccessResponse(decryptedView));
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof ErrorResponse) {
|
||||
if (e.statusCode === 404) {
|
||||
return Response.notFound();
|
||||
}
|
||||
}
|
||||
return Response.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
private async sendRequest(
|
||||
url: string,
|
||||
id: string,
|
||||
|
||||
@@ -133,6 +133,8 @@ export class SendProgram extends BaseProgram {
|
||||
this.serviceContainer.environmentService,
|
||||
this.serviceContainer.sendApiService,
|
||||
this.serviceContainer.apiService,
|
||||
this.serviceContainer.sendTokenService,
|
||||
this.serviceContainer.configService,
|
||||
);
|
||||
const response = await cmd.run(url, options);
|
||||
this.processResponse(response);
|
||||
|
||||
Reference in New Issue
Block a user