1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-19 19:04:01 +00:00

[PM-30922] Client changes to encrypt send access email list (#18486)

This commit is contained in:
John Harrington
2026-01-28 14:31:48 -07:00
committed by jaasen-livefront
parent 924b777e36
commit 62ae8aded7
21 changed files with 1685 additions and 66 deletions

View File

@@ -1031,6 +1031,8 @@ export default class MainBackground {
this.keyGenerationService,
this.sendStateProvider,
this.encryptService,
this.cryptoFunctionService,
this.configService,
);
this.sendApiService = new SendApiService(
this.apiService,

View File

@@ -18,5 +18,5 @@ export async function registerOssPrograms(serviceContainer: ServiceContainer) {
await vaultProgram.register();
const sendProgram = new SendProgram(serviceContainer);
sendProgram.register();
await sendProgram.register();
}

View File

@@ -608,6 +608,8 @@ export class ServiceContainer {
this.keyGenerationService,
this.sendStateProvider,
this.encryptService,
this.cryptoFunctionService,
this.configService,
);
this.cipherFileUploadService = new CipherFileUploadService(

View File

@@ -0,0 +1,386 @@
// 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 { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { mockAccountInfoWith } from "@bitwarden/common/spec";
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
import { AuthType } from "@bitwarden/common/tools/send/types/auth-type";
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
import { UserId } from "@bitwarden/user-core";
import { SendCreateCommand } from "./create.command";
describe("SendCreateCommand", () => {
let command: SendCreateCommand;
const sendService = mock<SendService>();
const environmentService = mock<EnvironmentService>();
const sendApiService = mock<SendApiService>();
const accountProfileService = mock<BillingAccountProfileStateService>();
const accountService = mock<AccountService>();
const activeAccount = {
id: "user-id" as UserId,
...mockAccountInfoWith({
email: "user@example.com",
name: "User",
}),
};
beforeEach(() => {
jest.clearAllMocks();
accountService.activeAccount$ = of(activeAccount);
accountProfileService.hasPremiumFromAnySource$.mockReturnValue(of(false));
environmentService.environment$ = of({
getWebVaultUrl: () => "https://vault.bitwarden.com",
} as any);
command = new SendCreateCommand(
sendService,
environmentService,
sendApiService,
accountProfileService,
accountService,
);
});
describe("authType inference", () => {
const futureDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
describe("with CLI flags", () => {
it("should set authType to Email when emails are provided via CLI", async () => {
const requestJson = {
type: SendType.Text,
text: { text: "test content", hidden: false },
deletionDate: futureDate,
};
const cmdOptions = {
email: ["test@example.com"],
};
sendService.encrypt.mockResolvedValue([
{ id: "send-id", emails: "test@example.com", authType: AuthType.Email } as any,
null as any,
]);
sendApiService.save.mockResolvedValue(undefined as any);
sendService.getFromState.mockResolvedValue({
decrypt: jest.fn().mockResolvedValue({}),
} as any);
const response = await command.run(requestJson, cmdOptions);
expect(response.success).toBe(true);
expect(sendService.encrypt).toHaveBeenCalledWith(
expect.objectContaining({
type: SendType.Text,
}),
null,
undefined,
);
const savedCall = sendApiService.save.mock.calls[0][0];
expect(savedCall[0].authType).toBe(AuthType.Email);
expect(savedCall[0].emails).toBe("test@example.com");
});
it("should set authType to Password when password is provided via CLI", async () => {
const requestJson = {
type: SendType.Text,
text: { text: "test content", hidden: false },
deletionDate: futureDate,
};
const cmdOptions = {
password: "testPassword123",
};
sendService.encrypt.mockResolvedValue([
{ id: "send-id", authType: AuthType.Password } as any,
null as any,
]);
sendApiService.save.mockResolvedValue(undefined as any);
sendService.getFromState.mockResolvedValue({
decrypt: jest.fn().mockResolvedValue({}),
} as any);
const response = await command.run(requestJson, cmdOptions);
expect(response.success).toBe(true);
expect(sendService.encrypt).toHaveBeenCalledWith(
expect.any(Object),
null as any,
"testPassword123",
);
const savedCall = sendApiService.save.mock.calls[0][0];
expect(savedCall[0].authType).toBe(AuthType.Password);
});
it("should set authType to None when neither emails nor password provided", async () => {
const requestJson = {
type: SendType.Text,
text: { text: "test content", hidden: false },
deletionDate: futureDate,
};
const cmdOptions = {};
sendService.encrypt.mockResolvedValue([
{ id: "send-id", authType: AuthType.None } as any,
null as any,
]);
sendApiService.save.mockResolvedValue(undefined as any);
sendService.getFromState.mockResolvedValue({
decrypt: jest.fn().mockResolvedValue({}),
} as any);
const response = await command.run(requestJson, cmdOptions);
expect(response.success).toBe(true);
expect(sendService.encrypt).toHaveBeenCalledWith(expect.any(Object), null, undefined);
const savedCall = sendApiService.save.mock.calls[0][0];
expect(savedCall[0].authType).toBe(AuthType.None);
});
it("should return error when both emails and password provided via CLI", async () => {
const requestJson = {
type: SendType.Text,
text: { text: "test content", hidden: false },
deletionDate: futureDate,
};
const cmdOptions = {
email: ["test@example.com"],
password: "testPassword123",
};
const response = await command.run(requestJson, cmdOptions);
expect(response.success).toBe(false);
expect(response.message).toBe("--password and --emails are mutually exclusive.");
});
});
describe("with JSON input", () => {
it("should set authType to Email when emails provided in JSON", async () => {
const requestJson = {
type: SendType.Text,
text: { text: "test content", hidden: false },
deletionDate: futureDate,
emails: ["test@example.com", "another@example.com"],
};
sendService.encrypt.mockResolvedValue([
{
id: "send-id",
emails: "test@example.com,another@example.com",
authType: AuthType.Email,
} as any,
null as any,
]);
sendApiService.save.mockResolvedValue(undefined as any);
sendService.getFromState.mockResolvedValue({
decrypt: jest.fn().mockResolvedValue({}),
} as any);
const response = await command.run(requestJson, {});
expect(response.success).toBe(true);
const savedCall = sendApiService.save.mock.calls[0][0];
expect(savedCall[0].authType).toBe(AuthType.Email);
expect(savedCall[0].emails).toBe("test@example.com,another@example.com");
});
it("should set authType to Password when password provided in JSON", async () => {
const requestJson = {
type: SendType.Text,
text: { text: "test content", hidden: false },
deletionDate: futureDate,
password: "jsonPassword123",
};
sendService.encrypt.mockResolvedValue([
{ id: "send-id", authType: AuthType.Password } as any,
null as any,
]);
sendApiService.save.mockResolvedValue(undefined as any);
sendService.getFromState.mockResolvedValue({
decrypt: jest.fn().mockResolvedValue({}),
} as any);
const response = await command.run(requestJson, {});
expect(response.success).toBe(true);
const savedCall = sendApiService.save.mock.calls[0][0];
expect(savedCall[0].authType).toBe(AuthType.Password);
});
it("should return error when both emails and password provided in JSON", async () => {
const requestJson = {
type: SendType.Text,
text: { text: "test content", hidden: false },
deletionDate: futureDate,
emails: ["test@example.com"],
password: "jsonPassword123",
};
const response = await command.run(requestJson, {});
expect(response.success).toBe(false);
expect(response.message).toBe("--password and --emails are mutually exclusive.");
});
});
describe("with mixed CLI and JSON input", () => {
it("should return error when CLI emails combined with JSON password", async () => {
const requestJson = {
type: SendType.Text,
text: { text: "test content", hidden: false },
deletionDate: futureDate,
password: "jsonPassword123",
};
const cmdOptions = {
email: ["cli@example.com"],
};
const response = await command.run(requestJson, cmdOptions);
expect(response.success).toBe(false);
expect(response.message).toBe("--password and --emails are mutually exclusive.");
});
it("should return error when CLI password combined with JSON emails", async () => {
const requestJson = {
type: SendType.Text,
text: { text: "test content", hidden: false },
deletionDate: futureDate,
emails: ["json@example.com"],
};
const cmdOptions = {
password: "cliPassword123",
};
const response = await command.run(requestJson, cmdOptions);
expect(response.success).toBe(false);
expect(response.message).toBe("--password and --emails are mutually exclusive.");
});
it("should use CLI value when JSON has different value of same type", async () => {
const requestJson = {
type: SendType.Text,
text: { text: "test content", hidden: false },
deletionDate: futureDate,
emails: ["json@example.com"],
};
const cmdOptions = {
email: ["cli@example.com"],
};
sendService.encrypt.mockResolvedValue([
{ id: "send-id", emails: "cli@example.com", authType: AuthType.Email } as any,
null as any,
]);
sendApiService.save.mockResolvedValue(undefined as any);
sendService.getFromState.mockResolvedValue({
decrypt: jest.fn().mockResolvedValue({}),
} as any);
const response = await command.run(requestJson, cmdOptions);
expect(response.success).toBe(true);
const savedCall = sendApiService.save.mock.calls[0][0];
expect(savedCall[0].authType).toBe(AuthType.Email);
expect(savedCall[0].emails).toBe("cli@example.com");
});
});
describe("edge cases", () => {
it("should set authType to None when emails array is empty", async () => {
const requestJson = {
type: SendType.Text,
text: { text: "test content", hidden: false },
deletionDate: futureDate,
emails: [] as string[],
};
sendService.encrypt.mockResolvedValue([
{ id: "send-id", authType: AuthType.None } as any,
null as any,
]);
sendApiService.save.mockResolvedValue(undefined as any);
sendService.getFromState.mockResolvedValue({
decrypt: jest.fn().mockResolvedValue({}),
} as any);
const response = await command.run(requestJson, {});
expect(response.success).toBe(true);
const savedCall = sendApiService.save.mock.calls[0][0];
expect(savedCall[0].authType).toBe(AuthType.None);
});
it("should set authType to None when password is empty string", async () => {
const requestJson = {
type: SendType.Text,
text: { text: "test content", hidden: false },
deletionDate: futureDate,
};
const cmdOptions = {
password: "",
};
sendService.encrypt.mockResolvedValue([
{ id: "send-id", authType: AuthType.None } as any,
null as any,
]);
sendApiService.save.mockResolvedValue(undefined as any);
sendService.getFromState.mockResolvedValue({
decrypt: jest.fn().mockResolvedValue({}),
} as any);
const response = await command.run(requestJson, cmdOptions);
expect(response.success).toBe(true);
const savedCall = sendApiService.save.mock.calls[0][0];
expect(savedCall[0].authType).toBe(AuthType.None);
});
it("should set authType to None when password is whitespace only", async () => {
const requestJson = {
type: SendType.Text,
text: { text: "test content", hidden: false },
deletionDate: futureDate,
};
const cmdOptions = {
password: " ",
};
sendService.encrypt.mockResolvedValue([
{ id: "send-id", authType: AuthType.None } as any,
null as any,
]);
sendApiService.save.mockResolvedValue(undefined as any);
sendService.getFromState.mockResolvedValue({
decrypt: jest.fn().mockResolvedValue({}),
} as any);
const response = await command.run(requestJson, cmdOptions);
expect(response.success).toBe(true);
const savedCall = sendApiService.save.mock.calls[0][0];
expect(savedCall[0].authType).toBe(AuthType.None);
});
});
});
});

View File

@@ -11,6 +11,7 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
import { AuthType } from "@bitwarden/common/tools/send/types/auth-type";
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
import { NodeUtils } from "@bitwarden/node/node-utils";
@@ -18,7 +19,6 @@ import { Response } from "../../../models/response";
import { CliUtils } from "../../../utils";
import { SendTextResponse } from "../models/send-text.response";
import { SendResponse } from "../models/send.response";
export class SendCreateCommand {
constructor(
private sendService: SendService,
@@ -81,12 +81,24 @@ export class SendCreateCommand {
const emails = req.emails ?? options.emails ?? undefined;
const maxAccessCount = req.maxAccessCount ?? options.maxAccessCount;
if (emails !== undefined && password !== undefined) {
const hasEmails = emails != null && emails.length > 0;
const hasPassword = password != null && password.trim().length > 0;
if (hasEmails && hasPassword) {
return Response.badRequest("--password and --emails are mutually exclusive.");
}
req.key = null;
req.maxAccessCount = maxAccessCount;
req.emails = emails;
if (hasEmails) {
req.authType = AuthType.Email;
} else if (hasPassword) {
req.authType = AuthType.Password;
} else {
req.authType = AuthType.None;
}
const hasPremium$ = this.accountService.activeAccount$.pipe(
switchMap(({ id }) => this.accountProfileService.hasPremiumFromAnySource$(id)),
@@ -136,11 +148,6 @@ export class SendCreateCommand {
const sendView = SendResponse.toView(req);
const [encSend, fileData] = await this.sendService.encrypt(sendView, fileBuffer, password);
// Add dates from template
encSend.deletionDate = sendView.deletionDate;
encSend.expirationDate = sendView.expirationDate;
encSend.emails = emails && emails.join(",");
await this.sendApiService.save([encSend, fileData]);
const newSend = await this.sendService.getFromState(encSend.id);
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));

View File

@@ -0,0 +1,400 @@
// 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 { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { mockAccountInfoWith } from "@bitwarden/common/spec";
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
import { AuthType } from "@bitwarden/common/tools/send/types/auth-type";
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
import { UserId } from "@bitwarden/user-core";
import { Response } from "../../../models/response";
import { SendResponse } from "../models/send.response";
import { SendEditCommand } from "./edit.command";
import { SendGetCommand } from "./get.command";
describe("SendEditCommand", () => {
let command: SendEditCommand;
const sendService = mock<SendService>();
const getCommand = mock<SendGetCommand>();
const sendApiService = mock<SendApiService>();
const accountProfileService = mock<BillingAccountProfileStateService>();
const accountService = mock<AccountService>();
const activeAccount = {
id: "user-id" as UserId,
...mockAccountInfoWith({
email: "user@example.com",
name: "User",
}),
};
const mockSendId = "send-123";
const mockSendView = {
id: mockSendId,
type: SendType.Text,
name: "Test Send",
text: { text: "test content", hidden: false },
deletionDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
} as SendView;
const mockSend = {
id: mockSendId,
type: SendType.Text,
decrypt: jest.fn().mockResolvedValue(mockSendView),
};
const encodeRequest = (data: any) => Buffer.from(JSON.stringify(data)).toString("base64");
beforeEach(() => {
jest.clearAllMocks();
accountService.activeAccount$ = of(activeAccount);
accountProfileService.hasPremiumFromAnySource$.mockReturnValue(of(false));
sendService.getFromState.mockResolvedValue(mockSend as any);
getCommand.run.mockResolvedValue(Response.success(new SendResponse(mockSendView)) as any);
command = new SendEditCommand(
sendService,
getCommand,
sendApiService,
accountProfileService,
accountService,
);
});
describe("authType inference", () => {
describe("with CLI flags", () => {
it("should set authType to Email when emails are provided via CLI", async () => {
const requestData = {
id: mockSendId,
type: SendType.Text,
name: "Test Send",
};
const requestJson = encodeRequest(requestData);
const cmdOptions = {
email: ["test@example.com"],
};
sendService.encrypt.mockResolvedValue([
{ id: mockSendId, emails: "test@example.com", authType: AuthType.Email } as any,
null as any,
]);
sendApiService.save.mockResolvedValue(undefined as any);
const response = await command.run(requestJson, cmdOptions);
expect(response.success).toBe(true);
const savedCall = sendApiService.save.mock.calls[0][0];
expect(savedCall[0].authType).toBe(AuthType.Email);
expect(savedCall[0].emails).toBe("test@example.com");
});
it("should set authType to Password when password is provided via CLI", async () => {
const requestData = {
id: mockSendId,
type: SendType.Text,
name: "Test Send",
};
const requestJson = encodeRequest(requestData);
const cmdOptions = {
password: "testPassword123",
};
sendService.encrypt.mockResolvedValue([
{ id: mockSendId, authType: AuthType.Password } as any,
null as any,
]);
sendApiService.save.mockResolvedValue(undefined as any);
const response = await command.run(requestJson, cmdOptions);
expect(response.success).toBe(true);
const savedCall = sendApiService.save.mock.calls[0][0];
expect(savedCall[0].authType).toBe(AuthType.Password);
});
it("should set authType to None when neither emails nor password provided", async () => {
const requestData = {
id: mockSendId,
type: SendType.Text,
name: "Test Send",
};
const requestJson = encodeRequest(requestData);
const cmdOptions = {};
sendService.encrypt.mockResolvedValue([
{ id: mockSendId, authType: AuthType.None } as any,
null as any,
]);
sendApiService.save.mockResolvedValue(undefined as any);
const response = await command.run(requestJson, cmdOptions);
expect(response.success).toBe(true);
const savedCall = sendApiService.save.mock.calls[0][0];
expect(savedCall[0].authType).toBe(AuthType.None);
});
it("should return error when both emails and password provided via CLI", async () => {
const requestData = {
id: mockSendId,
type: SendType.Text,
name: "Test Send",
};
const requestJson = encodeRequest(requestData);
const cmdOptions = {
email: ["test@example.com"],
password: "testPassword123",
};
const response = await command.run(requestJson, cmdOptions);
expect(response.success).toBe(false);
expect(response.message).toBe("--password and --emails are mutually exclusive.");
});
});
describe("with JSON input", () => {
it("should set authType to Email when emails provided in JSON", async () => {
const requestData = {
id: mockSendId,
type: SendType.Text,
name: "Test Send",
emails: ["test@example.com", "another@example.com"],
};
const requestJson = encodeRequest(requestData);
sendService.encrypt.mockResolvedValue([
{ id: mockSendId, authType: AuthType.Email } as any,
null as any,
]);
sendApiService.save.mockResolvedValue(undefined as any);
const response = await command.run(requestJson, {});
expect(response.success).toBe(true);
const savedCall = sendApiService.save.mock.calls[0][0];
expect(savedCall[0].authType).toBe(AuthType.Email);
});
it("should set authType to Password when password provided in JSON", async () => {
const requestData = {
id: mockSendId,
type: SendType.Text,
name: "Test Send",
password: "jsonPassword123",
};
const requestJson = encodeRequest(requestData);
sendService.encrypt.mockResolvedValue([
{ id: mockSendId, authType: AuthType.Password } as any,
null as any,
]);
sendApiService.save.mockResolvedValue(undefined as any);
const response = await command.run(requestJson, {});
expect(response.success).toBe(true);
const savedCall = sendApiService.save.mock.calls[0][0];
expect(savedCall[0].authType).toBe(AuthType.Password);
});
it("should return error when both emails and password provided in JSON", async () => {
const requestData = {
id: mockSendId,
type: SendType.Text,
name: "Test Send",
emails: ["test@example.com"],
password: "jsonPassword123",
};
const requestJson = encodeRequest(requestData);
const response = await command.run(requestJson, {});
expect(response.success).toBe(false);
expect(response.message).toBe("--password and --emails are mutually exclusive.");
});
});
describe("with mixed CLI and JSON input", () => {
it("should return error when CLI emails combined with JSON password", async () => {
const requestData = {
id: mockSendId,
type: SendType.Text,
name: "Test Send",
password: "jsonPassword123",
};
const requestJson = encodeRequest(requestData);
const cmdOptions = {
email: ["cli@example.com"],
};
const response = await command.run(requestJson, cmdOptions);
expect(response.success).toBe(false);
expect(response.message).toBe("--password and --emails are mutually exclusive.");
});
it("should return error when CLI password combined with JSON emails", async () => {
const requestData = {
id: mockSendId,
type: SendType.Text,
name: "Test Send",
emails: ["json@example.com"],
};
const requestJson = encodeRequest(requestData);
const cmdOptions = {
password: "cliPassword123",
};
const response = await command.run(requestJson, cmdOptions);
expect(response.success).toBe(false);
expect(response.message).toBe("--password and --emails are mutually exclusive.");
});
it("should prioritize CLI value when JSON has different value of same type", async () => {
const requestData = {
id: mockSendId,
type: SendType.Text,
name: "Test Send",
emails: ["json@example.com"],
};
const requestJson = encodeRequest(requestData);
const cmdOptions = {
email: ["cli@example.com"],
};
sendService.encrypt.mockResolvedValue([
{ id: mockSendId, emails: "cli@example.com", authType: AuthType.Email } as any,
null as any,
]);
sendApiService.save.mockResolvedValue(undefined as any);
const response = await command.run(requestJson, cmdOptions);
expect(response.success).toBe(true);
const savedCall = sendApiService.save.mock.calls[0][0];
expect(savedCall[0].authType).toBe(AuthType.Email);
expect(savedCall[0].emails).toBe("cli@example.com");
});
});
describe("edge cases", () => {
it("should set authType to None when emails array is empty", async () => {
const requestData = {
id: mockSendId,
type: SendType.Text,
name: "Test Send",
emails: [] as string[],
};
const requestJson = encodeRequest(requestData);
sendService.encrypt.mockResolvedValue([
{ id: mockSendId, authType: AuthType.None } as any,
null as any,
]);
sendApiService.save.mockResolvedValue(undefined as any);
const response = await command.run(requestJson, {});
expect(response.success).toBe(true);
const savedCall = sendApiService.save.mock.calls[0][0];
expect(savedCall[0].authType).toBe(AuthType.None);
});
it("should set authType to None when password is empty string", async () => {
const requestData = {
id: mockSendId,
type: SendType.Text,
name: "Test Send",
password: "",
};
const requestJson = encodeRequest(requestData);
sendService.encrypt.mockResolvedValue([
{ id: mockSendId, authType: AuthType.None } as any,
null as any,
]);
sendApiService.save.mockResolvedValue(undefined as any);
const response = await command.run(requestJson, {});
expect(response.success).toBe(true);
const savedCall = sendApiService.save.mock.calls[0][0];
expect(savedCall[0].authType).toBe(AuthType.None);
});
it("should handle send not found", async () => {
sendService.getFromState.mockResolvedValue(null);
const requestData = {
id: "nonexistent-id",
type: SendType.Text,
name: "Test Send",
};
const requestJson = encodeRequest(requestData);
const response = await command.run(requestJson, {});
expect(response.success).toBe(false);
});
it("should handle type mismatch", async () => {
const requestData = {
id: mockSendId,
type: SendType.File,
name: "Test Send",
};
const requestJson = encodeRequest(requestData);
const response = await command.run(requestJson, {});
expect(response.success).toBe(false);
expect(response.message).toBe("Cannot change a Send's type");
});
});
});
describe("validation", () => {
it("should return error when requestJson is empty", async () => {
// Set BW_SERVE to prevent readStdin call
process.env.BW_SERVE = "true";
const response = await command.run("", {});
expect(response.success).toBe(false);
expect(response.message).toBe("`requestJson` was not provided.");
delete process.env.BW_SERVE;
});
it("should return error when id is not provided", async () => {
const requestData = {
type: SendType.Text,
name: "Test Send",
};
const requestJson = encodeRequest(requestData);
const response = await command.run(requestJson, {});
expect(response.success).toBe(false);
expect(response.message).toBe("`itemid` was not provided.");
});
});
});

View File

@@ -7,6 +7,7 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
import { AuthType } from "@bitwarden/common/tools/send/types/auth-type";
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
import { Response } from "../../../models/response";
@@ -53,14 +54,30 @@ export class SendEditCommand {
req.id = normalizedOptions.itemId || req.id;
if (normalizedOptions.emails) {
req.emails = normalizedOptions.emails;
req.password = undefined;
} else if (normalizedOptions.password) {
req.emails = undefined;
}
if (normalizedOptions.password) {
req.password = normalizedOptions.password;
} else if (req.password && (typeof req.password !== "string" || req.password === "")) {
}
if (req.password && (typeof req.password !== "string" || req.password === "")) {
req.password = undefined;
}
// Infer authType based on emails/password (mutually exclusive)
const hasEmails = req.emails != null && req.emails.length > 0;
const hasPassword = req.password != null && req.password.trim() !== "";
if (hasEmails && hasPassword) {
return Response.badRequest("--password and --emails are mutually exclusive.");
}
if (hasEmails) {
req.authType = AuthType.Email;
} else if (hasPassword) {
req.authType = AuthType.Password;
} else {
req.authType = AuthType.None;
}
if (!req.id) {
return Response.error("`itemid` was not provided.");
}
@@ -90,10 +107,6 @@ export class SendEditCommand {
try {
const [encSend, encFileData] = await this.sendService.encrypt(sendView, null, req.password);
// Add dates from template
encSend.deletionDate = sendView.deletionDate;
encSend.expirationDate = sendView.expirationDate;
await this.sendApiService.save([encSend, encFileData]);
} catch (e) {
return Response.error(e);

View File

@@ -2,6 +2,7 @@
// @ts-strict-ignore
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import { AuthType } from "@bitwarden/common/tools/send/types/auth-type";
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
import { BaseResponse } from "../../../models/response/base.response";
@@ -54,6 +55,7 @@ export class SendResponse implements BaseResponse {
view.emails = send.emails ?? [];
view.disabled = send.disabled;
view.hideEmail = send.hideEmail;
view.authType = send.authType;
return view;
}
@@ -92,6 +94,7 @@ export class SendResponse implements BaseResponse {
emails?: Array<string>;
disabled: boolean;
hideEmail: boolean;
authType: AuthType;
constructor(o?: SendView, webVaultUrl?: string) {
if (o == null) {
@@ -116,8 +119,10 @@ export class SendResponse implements BaseResponse {
this.deletionDate = o.deletionDate;
this.expirationDate = o.expirationDate;
this.passwordSet = o.password != null;
this.emails = o.emails ?? [];
this.disabled = o.disabled;
this.hideEmail = o.hideEmail;
this.authType = o.authType;
if (o.type === SendType.Text && o.text != null) {
this.text = new SendTextResponse(o.text);

View File

@@ -6,6 +6,7 @@ import * as path from "path";
import * as chalk from "chalk";
import { program, Command, Option, OptionValues } from "commander";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
@@ -31,13 +32,16 @@ import { parseEmail } from "./util";
const writeLn = CliUtils.writeLn;
export class SendProgram extends BaseProgram {
register() {
program.addCommand(this.sendCommand());
async register() {
const emailAuthEnabled = await this.serviceContainer.configService.getFeatureFlag(
FeatureFlag.SendEmailOTP,
);
program.addCommand(this.sendCommand(emailAuthEnabled));
// receive is accessible both at `bw receive` and `bw send receive`
program.addCommand(this.receiveCommand());
}
private sendCommand(): Command {
private sendCommand(emailAuthEnabled: boolean): Command {
return new Command("send")
.argument("<data>", "The data to Send. Specify as a filepath with the --file option")
.description(
@@ -59,9 +63,7 @@ export class SendProgram extends BaseProgram {
new Option(
"--email <email>",
"optional emails to access this Send. Can also be specified in JSON.",
)
.argParser(parseEmail)
.hideHelp(),
).argParser(parseEmail),
)
.option("-a, --maxAccessCount <amount>", "The amount of max possible accesses.")
.option("--hidden", "Hide <data> in web by default. Valid only if --file is not set.")
@@ -78,11 +80,18 @@ export class SendProgram extends BaseProgram {
.addCommand(this.templateCommand())
.addCommand(this.getCommand())
.addCommand(this.receiveCommand())
.addCommand(this.createCommand())
.addCommand(this.editCommand())
.addCommand(this.createCommand(emailAuthEnabled))
.addCommand(this.editCommand(emailAuthEnabled))
.addCommand(this.removePasswordCommand())
.addCommand(this.deleteCommand())
.action(async (data: string, options: OptionValues) => {
if (options.email) {
if (!emailAuthEnabled) {
this.processResponse(Response.error("The --email feature is not currently available."));
return;
}
}
const encodedJson = this.makeSendJson(data, options);
let response: Response;
@@ -199,7 +208,7 @@ export class SendProgram extends BaseProgram {
});
}
private createCommand(): Command {
private createCommand(emailAuthEnabled: any): Command {
return new Command("create")
.argument("[encodedJson]", "JSON object to upload. Can also be piped in through stdin.")
.description("create a Send")
@@ -215,6 +224,14 @@ export class SendProgram extends BaseProgram {
.action(async (encodedJson: string, options: OptionValues, args: { parent: Command }) => {
// subcommands inherit flags from their parent; they cannot override them
const { fullObject = false, email = undefined, password = undefined } = args.parent.opts();
if (email) {
if (!emailAuthEnabled) {
this.processResponse(Response.error("The --email feature is not currently available."));
return;
}
}
const mergedOptions = {
...options,
fullObject: fullObject,
@@ -227,7 +244,7 @@ export class SendProgram extends BaseProgram {
});
}
private editCommand(): Command {
private editCommand(emailAuthEnabled: any): Command {
return new Command("edit")
.argument(
"[encodedJson]",
@@ -243,6 +260,14 @@ export class SendProgram extends BaseProgram {
})
.action(async (encodedJson: string, options: OptionValues, args: { parent: Command }) => {
await this.exitIfLocked();
const { email = undefined, password = undefined } = args.parent.opts();
if (email) {
if (!emailAuthEnabled) {
this.processResponse(Response.error("The --email feature is not currently available."));
return;
}
}
const getCmd = new SendGetCommand(
this.serviceContainer.sendService,
this.serviceContainer.environmentService,
@@ -259,8 +284,6 @@ export class SendProgram extends BaseProgram {
this.serviceContainer.accountService,
);
// subcommands inherit flags from their parent; they cannot override them
const { email = undefined, password = undefined } = args.parent.opts();
const mergedOptions = {
...options,
email,
@@ -328,6 +351,7 @@ export class SendProgram extends BaseProgram {
file: sendFile,
text: sendText,
type: type,
emails: options.email ?? undefined,
});
return Buffer.from(JSON.stringify(template), "utf8").toString("base64");

View File

@@ -858,6 +858,8 @@ const safeProviders: SafeProvider[] = [
KeyGenerationService,
SendStateProviderAbstraction,
EncryptService,
CryptoFunctionServiceAbstraction,
ConfigService,
],
}),
safeProvider({

View File

@@ -11,7 +11,6 @@ export class SendData {
id: string;
accessId: string;
type: SendType;
authType: AuthType;
name: string;
notes: string;
file: SendFileData;
@@ -24,8 +23,10 @@ export class SendData {
deletionDate: string;
password: string;
emails: string;
emailHashes: string;
disabled: boolean;
hideEmail: boolean;
authType: AuthType;
constructor(response?: SendResponse) {
if (response == null) {
@@ -46,8 +47,10 @@ export class SendData {
this.deletionDate = response.deletionDate;
this.password = response.password;
this.emails = response.emails;
this.emailHashes = "";
this.disabled = response.disable;
this.hideEmail = response.hideEmail;
this.authType = response.authType;
switch (this.type) {
case SendType.Text:

View File

@@ -1,6 +1,7 @@
import { mock } from "jest-mock-extended";
import { of } from "rxjs";
import { Send } from "@bitwarden/common/tools/send/models/domain/send";
import { emptyGuid, UserId } from "@bitwarden/common/types/guid";
// 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
@@ -15,7 +16,6 @@ import { AuthType } from "../../types/auth-type";
import { SendType } from "../../types/send-type";
import { SendData } from "../data/send.data";
import { Send } from "./send";
import { SendText } from "./send-text";
describe("Send", () => {
@@ -26,7 +26,6 @@ describe("Send", () => {
id: "id",
accessId: "accessId",
type: SendType.Text,
authType: AuthType.None,
name: "encName",
notes: "encNotes",
text: {
@@ -41,9 +40,11 @@ describe("Send", () => {
expirationDate: "2022-01-31T12:00:00.000Z",
deletionDate: "2022-01-31T12:00:00.000Z",
password: "password",
emails: null!,
emails: "",
emailHashes: "",
disabled: false,
hideEmail: true,
authType: AuthType.None,
};
mockContainerService();
@@ -69,6 +70,8 @@ describe("Send", () => {
expirationDate: null,
deletionDate: null,
password: undefined,
emails: null,
emailHashes: undefined,
disabled: undefined,
hideEmail: undefined,
});
@@ -81,7 +84,6 @@ describe("Send", () => {
id: "id",
accessId: "accessId",
type: SendType.Text,
authType: AuthType.None,
name: { encryptedString: "encName", encryptionType: 0 },
notes: { encryptedString: "encNotes", encryptionType: 0 },
text: {
@@ -95,9 +97,11 @@ describe("Send", () => {
expirationDate: new Date("2022-01-31T12:00:00.000Z"),
deletionDate: new Date("2022-01-31T12:00:00.000Z"),
password: "password",
emails: null!,
emails: null,
emailHashes: "",
disabled: false,
hideEmail: true,
authType: AuthType.None,
});
});
@@ -121,14 +125,22 @@ describe("Send", () => {
send.expirationDate = new Date("2022-01-31T12:00:00.000Z");
send.deletionDate = new Date("2022-01-31T12:00:00.000Z");
send.password = "password";
send.emails = null;
send.disabled = false;
send.hideEmail = true;
send.authType = AuthType.None;
const encryptService = mock<EncryptService>();
const keyService = mock<KeyService>();
encryptService.decryptBytes
.calledWith(send.key, userKey)
.mockResolvedValue(makeStaticByteArray(32));
encryptService.decryptString
.calledWith(send.name, "cryptoKey" as any)
.mockResolvedValue("name");
encryptService.decryptString
.calledWith(send.notes, "cryptoKey" as any)
.mockResolvedValue("notes");
keyService.makeSendKey.mockResolvedValue("cryptoKey" as any);
keyService.userKey$.calledWith(userId).mockReturnValue(of(userKey));
@@ -137,12 +149,6 @@ describe("Send", () => {
const view = await send.decrypt(userId);
expect(text.decrypt).toHaveBeenNthCalledWith(1, "cryptoKey");
expect(send.name.decrypt).toHaveBeenNthCalledWith(
1,
null,
"cryptoKey",
"Property: name; ObjectContext: No Domain Context",
);
expect(view).toMatchObject({
id: "id",
@@ -150,7 +156,6 @@ describe("Send", () => {
name: "name",
notes: "notes",
type: 0,
authType: 2,
key: expect.anything(),
cryptoKey: "cryptoKey",
file: expect.anything(),
@@ -161,8 +166,265 @@ describe("Send", () => {
expirationDate: new Date("2022-01-31T12:00:00.000Z"),
deletionDate: new Date("2022-01-31T12:00:00.000Z"),
password: "password",
emails: [],
disabled: false,
hideEmail: true,
authType: AuthType.None,
});
});
describe("Email decryption", () => {
let encryptService: jest.Mocked<EncryptService>;
let keyService: jest.Mocked<KeyService>;
const userKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey;
const userId = emptyGuid as UserId;
beforeEach(() => {
encryptService = mock<EncryptService>();
keyService = mock<KeyService>();
encryptService.decryptBytes.mockResolvedValue(makeStaticByteArray(32));
keyService.makeSendKey.mockResolvedValue("cryptoKey" as any);
keyService.userKey$.mockReturnValue(of(userKey));
(window as any).bitwardenContainerService = new ContainerService(keyService, encryptService);
});
it("should decrypt and parse single email", async () => {
const send = new Send();
send.id = "id";
send.type = SendType.Text;
send.name = mockEnc("name");
send.notes = mockEnc("notes");
send.key = mockEnc("key");
send.emails = mockEnc("test@example.com");
send.text = mock<SendText>();
send.text.decrypt = jest.fn().mockResolvedValue("textView" as any);
encryptService.decryptString.mockImplementation((encString, key) => {
if (encString === send.emails) {
return Promise.resolve("test@example.com");
}
if (encString === send.name) {
return Promise.resolve("name");
}
if (encString === send.notes) {
return Promise.resolve("notes");
}
return Promise.resolve("");
});
const view = await send.decrypt(userId);
expect(encryptService.decryptString).toHaveBeenCalledWith(send.emails, "cryptoKey");
expect(view.emails).toEqual(["test@example.com"]);
});
it("should decrypt and parse multiple emails", async () => {
const send = new Send();
send.id = "id";
send.type = SendType.Text;
send.name = mockEnc("name");
send.notes = mockEnc("notes");
send.key = mockEnc("key");
send.emails = mockEnc("test@example.com,user@test.com,admin@domain.com");
send.text = mock<SendText>();
send.text.decrypt = jest.fn().mockResolvedValue("textView" as any);
encryptService.decryptString.mockImplementation((encString, key) => {
if (encString === send.emails) {
return Promise.resolve("test@example.com,user@test.com,admin@domain.com");
}
if (encString === send.name) {
return Promise.resolve("name");
}
if (encString === send.notes) {
return Promise.resolve("notes");
}
return Promise.resolve("");
});
const view = await send.decrypt(userId);
expect(view.emails).toEqual(["test@example.com", "user@test.com", "admin@domain.com"]);
});
it("should trim whitespace from decrypted emails", async () => {
const send = new Send();
send.id = "id";
send.type = SendType.Text;
send.name = mockEnc("name");
send.notes = mockEnc("notes");
send.key = mockEnc("key");
send.emails = mockEnc(" test@example.com , user@test.com ");
send.text = mock<SendText>();
send.text.decrypt = jest.fn().mockResolvedValue("textView" as any);
encryptService.decryptString.mockImplementation((encString, key) => {
if (encString === send.emails) {
return Promise.resolve(" test@example.com , user@test.com ");
}
if (encString === send.name) {
return Promise.resolve("name");
}
if (encString === send.notes) {
return Promise.resolve("notes");
}
return Promise.resolve("");
});
const view = await send.decrypt(userId);
expect(view.emails).toEqual(["test@example.com", "user@test.com"]);
});
it("should return empty array when emails is null", async () => {
const send = new Send();
send.id = "id";
send.type = SendType.Text;
send.name = mockEnc("name");
send.notes = mockEnc("notes");
send.key = mockEnc("key");
send.emails = null;
send.text = mock<SendText>();
send.text.decrypt = jest.fn().mockResolvedValue("textView" as any);
const view = await send.decrypt(userId);
expect(view.emails).toEqual([]);
expect(encryptService.decryptString).not.toHaveBeenCalledWith(expect.anything(), "cryptoKey");
});
it("should return empty array when decrypted emails is empty string", async () => {
const send = new Send();
send.id = "id";
send.type = SendType.Text;
send.name = mockEnc("name");
send.notes = mockEnc("notes");
send.key = mockEnc("key");
send.emails = mockEnc("");
send.text = mock<SendText>();
send.text.decrypt = jest.fn().mockResolvedValue("textView" as any);
encryptService.decryptString.mockImplementation((encString, key) => {
if (encString === send.emails) {
return Promise.resolve("");
}
if (encString === send.name) {
return Promise.resolve("name");
}
if (encString === send.notes) {
return Promise.resolve("notes");
}
return Promise.resolve("");
});
const view = await send.decrypt(userId);
expect(view.emails).toEqual([]);
});
it("should return empty array when decrypted emails is null", async () => {
const send = new Send();
send.id = "id";
send.type = SendType.Text;
send.name = mockEnc("name");
send.notes = mockEnc("notes");
send.key = mockEnc("key");
send.emails = mockEnc("something");
send.text = mock<SendText>();
send.text.decrypt = jest.fn().mockResolvedValue("textView" as any);
encryptService.decryptString.mockImplementation((encString, key) => {
if (encString === send.emails) {
return Promise.resolve(null);
}
if (encString === send.name) {
return Promise.resolve("name");
}
if (encString === send.notes) {
return Promise.resolve("notes");
}
return Promise.resolve("");
});
const view = await send.decrypt(userId);
expect(view.emails).toEqual([]);
});
});
describe("Null handling for name and notes decryption", () => {
let encryptService: jest.Mocked<EncryptService>;
let keyService: jest.Mocked<KeyService>;
const userKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey;
const userId = emptyGuid as UserId;
beforeEach(() => {
encryptService = mock<EncryptService>();
keyService = mock<KeyService>();
encryptService.decryptBytes.mockResolvedValue(makeStaticByteArray(32));
keyService.makeSendKey.mockResolvedValue("cryptoKey" as any);
keyService.userKey$.mockReturnValue(of(userKey));
(window as any).bitwardenContainerService = new ContainerService(keyService, encryptService);
});
it("should return null for name when name is null", async () => {
const send = new Send();
send.id = "id";
send.type = SendType.Text;
send.name = null;
send.notes = mockEnc("notes");
send.key = mockEnc("key");
send.emails = null;
send.text = mock<SendText>();
send.text.decrypt = jest.fn().mockResolvedValue("textView" as any);
const view = await send.decrypt(userId);
expect(view.name).toBeNull();
expect(encryptService.decryptString).not.toHaveBeenCalledWith(null, expect.anything());
});
it("should return null for notes when notes is null", async () => {
const send = new Send();
send.id = "id";
send.type = SendType.Text;
send.name = mockEnc("name");
send.notes = null;
send.key = mockEnc("key");
send.emails = null;
send.text = mock<SendText>();
send.text.decrypt = jest.fn().mockResolvedValue("textView" as any);
const view = await send.decrypt(userId);
expect(view.notes).toBeNull();
});
it("should decrypt non-null name and notes", async () => {
const send = new Send();
send.id = "id";
send.type = SendType.Text;
send.name = mockEnc("Test Name");
send.notes = mockEnc("Test Notes");
send.key = mockEnc("key");
send.emails = null;
send.text = mock<SendText>();
send.text.decrypt = jest.fn().mockResolvedValue("textView" as any);
encryptService.decryptString.mockImplementation((encString, key) => {
if (encString === send.name) {
return Promise.resolve("Test Name");
}
if (encString === send.notes) {
return Promise.resolve("Test Notes");
}
return Promise.resolve("");
});
const view = await send.decrypt(userId);
expect(view.name).toBe("Test Name");
expect(view.notes).toBe("Test Notes");
});
});
});

View File

@@ -20,7 +20,6 @@ export class Send extends Domain {
id: string;
accessId: string;
type: SendType;
authType: AuthType;
name: EncString;
notes: EncString;
file: SendFile;
@@ -32,9 +31,11 @@ export class Send extends Domain {
expirationDate: Date;
deletionDate: Date;
password: string;
emails: string;
emails: EncString;
emailHashes: string;
disabled: boolean;
hideEmail: boolean;
authType: AuthType;
constructor(obj?: SendData) {
super();
@@ -51,6 +52,7 @@ export class Send extends Domain {
name: null,
notes: null,
key: null,
emails: null,
},
["id", "accessId"],
);
@@ -60,12 +62,13 @@ export class Send extends Domain {
this.maxAccessCount = obj.maxAccessCount;
this.accessCount = obj.accessCount;
this.password = obj.password;
this.emails = obj.emails;
this.emailHashes = obj.emailHashes;
this.disabled = obj.disabled;
this.revisionDate = obj.revisionDate != null ? new Date(obj.revisionDate) : null;
this.deletionDate = obj.deletionDate != null ? new Date(obj.deletionDate) : null;
this.expirationDate = obj.expirationDate != null ? new Date(obj.expirationDate) : null;
this.hideEmail = obj.hideEmail;
this.authType = obj.authType;
switch (this.type) {
case SendType.Text:
@@ -91,8 +94,17 @@ export class Send extends Domain {
// model.key is a seed used to derive a key, not a SymmetricCryptoKey
model.key = await encryptService.decryptBytes(this.key, sendKeyEncryptionKey);
model.cryptoKey = await keyService.makeSendKey(model.key);
model.name =
this.name != null ? await encryptService.decryptString(this.name, model.cryptoKey) : null;
model.notes =
this.notes != null ? await encryptService.decryptString(this.notes, model.cryptoKey) : null;
await this.decryptObj<Send, SendView>(this, model, ["name", "notes"], model.cryptoKey);
if (this.emails != null) {
const decryptedEmails = await encryptService.decryptString(this.emails, model.cryptoKey);
model.emails = decryptedEmails ? decryptedEmails.split(",").map((e) => e.trim()) : [];
} else {
model.emails = [];
}
switch (this.type) {
case SendType.File:
@@ -121,6 +133,7 @@ export class Send extends Domain {
key: EncString.fromJSON(obj.key),
name: EncString.fromJSON(obj.name),
notes: EncString.fromJSON(obj.notes),
emails: EncString.fromJSON(obj.emails),
text: SendText.fromJSON(obj.text),
file: SendFile.fromJSON(obj.file),
revisionDate,

View File

@@ -0,0 +1,192 @@
import { Send } from "@bitwarden/common/tools/send/models/domain/send";
import { EncString } from "../../../../key-management/crypto/models/enc-string";
import { SendType } from "../../types/send-type";
import { SendText } from "../domain/send-text";
import { SendRequest } from "./send.request";
describe("SendRequest", () => {
describe("constructor", () => {
it("should populate emails with encrypted string from Send.emails", () => {
const send = new Send();
send.type = SendType.Text;
send.name = new EncString("encryptedName");
send.notes = new EncString("encryptedNotes");
send.key = new EncString("encryptedKey");
send.emails = new EncString("encryptedEmailList");
send.emailHashes = "HASH1,HASH2,HASH3";
send.disabled = false;
send.hideEmail = false;
send.text = new SendText();
send.text.text = new EncString("text");
send.text.hidden = false;
const request = new SendRequest(send);
expect(request.emails).toBe("encryptedEmailList");
});
it("should populate emailHashes from Send.emailHashes", () => {
const send = new Send();
send.type = SendType.Text;
send.name = new EncString("encryptedName");
send.notes = new EncString("encryptedNotes");
send.key = new EncString("encryptedKey");
send.emails = new EncString("encryptedEmailList");
send.emailHashes = "HASH1,HASH2,HASH3";
send.disabled = false;
send.hideEmail = false;
send.text = new SendText();
send.text.text = new EncString("text");
send.text.hidden = false;
const request = new SendRequest(send);
expect(request.emailHashes).toBe("HASH1,HASH2,HASH3");
});
it("should set emails to null when Send.emails is null", () => {
const send = new Send();
send.type = SendType.Text;
send.name = new EncString("encryptedName");
send.notes = new EncString("encryptedNotes");
send.key = new EncString("encryptedKey");
send.emails = null;
send.emailHashes = "";
send.disabled = false;
send.hideEmail = false;
send.text = new SendText();
send.text.text = new EncString("text");
send.text.hidden = false;
const request = new SendRequest(send);
expect(request.emails).toBeNull();
expect(request.emailHashes).toBe("");
});
it("should handle empty emailHashes", () => {
const send = new Send();
send.type = SendType.Text;
send.name = new EncString("encryptedName");
send.key = new EncString("encryptedKey");
send.emails = null;
send.emailHashes = "";
send.disabled = false;
send.hideEmail = false;
send.text = new SendText();
send.text.text = new EncString("text");
send.text.hidden = false;
const request = new SendRequest(send);
expect(request.emailHashes).toBe("");
});
it("should not expose plaintext emails", () => {
const send = new Send();
send.type = SendType.Text;
send.name = new EncString("encryptedName");
send.key = new EncString("encryptedKey");
send.emails = new EncString("2.encrypted|emaildata|here");
send.emailHashes = "ABC123,DEF456";
send.disabled = false;
send.hideEmail = false;
send.text = new SendText();
send.text.text = new EncString("text");
send.text.hidden = false;
const request = new SendRequest(send);
// Ensure the request contains the encrypted string format, not plaintext
expect(request.emails).toBe("2.encrypted|emaildata|here");
expect(request.emails).not.toContain("@");
});
it("should handle name being null", () => {
const send = new Send();
send.type = SendType.Text;
send.name = null;
send.notes = new EncString("encryptedNotes");
send.key = new EncString("encryptedKey");
send.emails = null;
send.emailHashes = "";
send.disabled = false;
send.hideEmail = false;
send.text = new SendText();
send.text.text = new EncString("text");
send.text.hidden = false;
const request = new SendRequest(send);
expect(request.name).toBeNull();
});
it("should handle notes being null", () => {
const send = new Send();
send.type = SendType.Text;
send.name = new EncString("encryptedName");
send.notes = null;
send.key = new EncString("encryptedKey");
send.emails = null;
send.emailHashes = "";
send.disabled = false;
send.hideEmail = false;
send.text = new SendText();
send.text.text = new EncString("text");
send.text.hidden = false;
const request = new SendRequest(send);
expect(request.notes).toBeNull();
});
it("should include fileLength when provided for text send", () => {
const send = new Send();
send.type = SendType.Text;
send.name = new EncString("encryptedName");
send.key = new EncString("encryptedKey");
send.emails = null;
send.emailHashes = "";
send.disabled = false;
send.hideEmail = false;
send.text = new SendText();
send.text.text = new EncString("text");
send.text.hidden = false;
const request = new SendRequest(send, 1024);
expect(request.fileLength).toBe(1024);
});
});
describe("Email auth requirements", () => {
it("should create request with encrypted emails and plaintext emailHashes", () => {
// Setup: A Send with encrypted emails and computed hashes
const send = new Send();
send.type = SendType.Text;
send.name = new EncString("encryptedName");
send.key = new EncString("encryptedKey");
send.emails = new EncString("2.encryptedEmailString|data");
send.emailHashes = "A1B2C3D4,E5F6G7H8"; // Plaintext hashes
send.disabled = false;
send.hideEmail = false;
send.text = new SendText();
send.text.text = new EncString("text");
send.text.hidden = false;
// Act: Create the request
const request = new SendRequest(send);
// emails field contains encrypted value
expect(request.emails).toBe("2.encryptedEmailString|data");
expect(request.emails).toContain("encrypted");
//emailHashes field contains plaintext comma-separated hashes
expect(request.emailHashes).toBe("A1B2C3D4,E5F6G7H8");
expect(request.emailHashes).not.toContain("encrypted");
expect(request.emailHashes.split(",")).toHaveLength(2);
});
});
});

View File

@@ -18,6 +18,7 @@ export class SendRequest {
file: SendFileApi;
password: string;
emails: string;
emailHashes: string;
disabled: boolean;
hideEmail: boolean;
@@ -31,7 +32,8 @@ export class SendRequest {
this.deletionDate = send.deletionDate != null ? send.deletionDate.toISOString() : null;
this.key = send.key != null ? send.key.encryptedString : null;
this.password = send.password;
this.emails = send.emails;
this.emails = send.emails ? send.emails.encryptedString : null;
this.emailHashes = send.emailHashes;
this.disabled = send.disabled;
this.hideEmail = send.hideEmail;

View File

@@ -1,8 +1,9 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { AuthType } from "@bitwarden/common/tools/send/types/auth-type";
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
import { BaseResponse } from "../../../../models/response/base.response";
import { AuthType } from "../../types/auth-type";
import { SendType } from "../../types/send-type";
import { SendFileApi } from "../api/send-file.api";
import { SendTextApi } from "../api/send-text.api";
@@ -10,7 +11,6 @@ export class SendResponse extends BaseResponse {
id: string;
accessId: string;
type: SendType;
authType: AuthType;
name: string;
notes: string;
file: SendFileApi;
@@ -25,6 +25,7 @@ export class SendResponse extends BaseResponse {
emails: string;
disable: boolean;
hideEmail: boolean;
authType: AuthType;
constructor(response: any) {
super(response);
@@ -44,6 +45,7 @@ export class SendResponse extends BaseResponse {
this.emails = this.getResponseProperty("Emails");
this.disable = this.getResponseProperty("Disabled") || false;
this.hideEmail = this.getResponseProperty("HideEmail") || false;
this.authType = this.getResponseProperty("AuthType");
const text = this.getResponseProperty("Text");
if (text != null) {

View File

@@ -19,7 +19,6 @@ export class SendView implements View {
key: Uint8Array;
cryptoKey: SymmetricCryptoKey;
type: SendType = null;
authType: AuthType = null;
text = new SendTextView();
file = new SendFileView();
maxAccessCount?: number = null;
@@ -31,6 +30,7 @@ export class SendView implements View {
emails: string[] = [];
disabled = false;
hideEmail = false;
authType: AuthType = null;
constructor(s?: Send) {
if (!s) {
@@ -49,6 +49,7 @@ export class SendView implements View {
this.disabled = s.disabled;
this.password = s.password;
this.hideEmail = s.hideEmail;
this.authType = s.authType;
}
get urlB64Key(): string {

View File

@@ -189,6 +189,7 @@ export class SendApiService implements SendApiServiceAbstraction {
private async upload(sendData: [Send, EncArrayBuffer]): Promise<SendResponse> {
const request = new SendRequest(sendData[0], sendData[1]?.buffer.byteLength);
let response: SendResponse;
if (sendData[0].id == null) {
if (sendData[0].type === SendType.Text) {

View File

@@ -1,6 +1,7 @@
import { mock } from "jest-mock-extended";
import { firstValueFrom, of } from "rxjs";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
// 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 { KeyService } from "@bitwarden/key-management";
@@ -16,6 +17,7 @@ import {
import { KeyGenerationService } from "../../../key-management/crypto";
import { EncryptService } from "../../../key-management/crypto/abstractions/encrypt.service";
import { EncString } from "../../../key-management/crypto/models/enc-string";
import { ConfigService } from "../../../platform/abstractions/config/config.service";
import { EnvironmentService } from "../../../platform/abstractions/environment.service";
import { I18nService } from "../../../platform/abstractions/i18n.service";
import { Utils } from "../../../platform/misc/utils";
@@ -29,6 +31,7 @@ import { SendTextApi } from "../models/api/send-text.api";
import { SendFileData } from "../models/data/send-file.data";
import { SendTextData } from "../models/data/send-text.data";
import { SendData } from "../models/data/send.data";
import { SendTextView } from "../models/view/send-text.view";
import { SendView } from "../models/view/send.view";
import { SendType } from "../types/send-type";
@@ -48,7 +51,8 @@ describe("SendService", () => {
const keyGenerationService = mock<KeyGenerationService>();
const encryptService = mock<EncryptService>();
const environmentService = mock<EnvironmentService>();
const cryptoFunctionService = mock<CryptoFunctionService>();
const configService = mock<ConfigService>();
let sendStateProvider: SendStateProvider;
let sendService: SendService;
@@ -94,6 +98,8 @@ describe("SendService", () => {
keyGenerationService,
sendStateProvider,
encryptService,
cryptoFunctionService,
configService,
);
});
@@ -573,4 +579,256 @@ describe("SendService", () => {
expect(sendsAfterDelete.length).toBe(0);
});
});
describe("encrypt", () => {
let sendView: SendView;
const userKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey;
const mockCryptoKey = new SymmetricCryptoKey(new Uint8Array(32));
beforeEach(() => {
sendView = new SendView();
sendView.id = "sendId";
sendView.type = SendType.Text;
sendView.name = "Test Send";
sendView.notes = "Test Notes";
const sendTextView = new SendTextView();
sendTextView.text = "test text";
sendTextView.hidden = false;
sendView.text = sendTextView;
sendView.key = new Uint8Array(16);
sendView.cryptoKey = mockCryptoKey;
sendView.maxAccessCount = 5;
sendView.disabled = false;
sendView.hideEmail = false;
sendView.deletionDate = new Date("2024-12-31");
sendView.expirationDate = null;
keyService.userKey$.mockReturnValue(of(userKey));
keyService.makeSendKey.mockResolvedValue(mockCryptoKey);
encryptService.encryptBytes.mockResolvedValue({ encryptedString: "encryptedKey" } as any);
encryptService.encryptString.mockResolvedValue({ encryptedString: "encrypted" } as any);
});
describe("when SendEmailOTP feature flag is ON", () => {
beforeEach(() => {
configService.getFeatureFlag.mockResolvedValue(true);
cryptoFunctionService.hash.mockClear();
});
describe("email encryption", () => {
it("should encrypt emails when email list is provided", async () => {
sendView.emails = ["test@example.com", "user@test.com"];
cryptoFunctionService.hash.mockResolvedValue(new Uint8Array([0xab, 0xcd]));
const [send] = await sendService.encrypt(sendView, null, null);
expect(encryptService.encryptString).toHaveBeenCalledWith(
"test@example.com,user@test.com",
mockCryptoKey,
);
expect(send.emails).toEqual({ encryptedString: "encrypted" });
expect(send.password).toBeNull();
});
it("should set emails to null when email list is empty", async () => {
sendView.emails = [];
const [send] = await sendService.encrypt(sendView, null, null);
expect(send.emails).toBeNull();
expect(send.emailHashes).toBe("");
});
it("should set emails to null when email list is null", async () => {
sendView.emails = null;
const [send] = await sendService.encrypt(sendView, null, null);
expect(send.emails).toBeNull();
expect(send.emailHashes).toBe("");
});
it("should set emails to null when email list is undefined", async () => {
sendView.emails = undefined;
const [send] = await sendService.encrypt(sendView, null, null);
expect(send.emails).toBeNull();
expect(send.emailHashes).toBe("");
});
});
describe("email hashing", () => {
it("should hash emails using SHA-256 and return uppercase hex", async () => {
sendView.emails = ["test@example.com"];
const mockHash = new Uint8Array([0xab, 0xcd, 0xef]);
cryptoFunctionService.hash.mockResolvedValue(mockHash);
const [send] = await sendService.encrypt(sendView, null, null);
expect(cryptoFunctionService.hash).toHaveBeenCalledWith("test@example.com", "sha256");
expect(send.emailHashes).toBe("ABCDEF");
});
it("should hash multiple emails and return comma-separated hashes", async () => {
sendView.emails = ["test@example.com", "user@test.com"];
const mockHash1 = new Uint8Array([0xab, 0xcd]);
const mockHash2 = new Uint8Array([0x12, 0x34]);
cryptoFunctionService.hash
.mockResolvedValueOnce(mockHash1)
.mockResolvedValueOnce(mockHash2);
const [send] = await sendService.encrypt(sendView, null, null);
expect(cryptoFunctionService.hash).toHaveBeenCalledWith("test@example.com", "sha256");
expect(cryptoFunctionService.hash).toHaveBeenCalledWith("user@test.com", "sha256");
expect(send.emailHashes).toBe("ABCD,1234");
});
it("should trim and lowercase emails before hashing", async () => {
sendView.emails = [" Test@Example.COM ", "USER@test.com"];
const mockHash = new Uint8Array([0xff]);
cryptoFunctionService.hash.mockResolvedValue(mockHash);
await sendService.encrypt(sendView, null, null);
expect(cryptoFunctionService.hash).toHaveBeenCalledWith("test@example.com", "sha256");
expect(cryptoFunctionService.hash).toHaveBeenCalledWith("user@test.com", "sha256");
});
it("should set emailHashes to empty string when no emails", async () => {
sendView.emails = [];
const [send] = await sendService.encrypt(sendView, null, null);
expect(send.emailHashes).toBe("");
expect(cryptoFunctionService.hash).not.toHaveBeenCalled();
});
it("should handle single email correctly", async () => {
sendView.emails = ["single@test.com"];
const mockHash = new Uint8Array([0xa1, 0xb2, 0xc3]);
cryptoFunctionService.hash.mockResolvedValue(mockHash);
const [send] = await sendService.encrypt(sendView, null, null);
expect(send.emailHashes).toBe("A1B2C3");
});
});
describe("emails and password mutual exclusivity", () => {
it("should set password to null when emails are provided", async () => {
sendView.emails = ["test@example.com"];
const [send] = await sendService.encrypt(sendView, null, "password123");
expect(send.emails).toBeDefined();
expect(send.password).toBeNull();
});
it("should set password when no emails are provided", async () => {
sendView.emails = [];
keyGenerationService.deriveKeyFromPassword.mockResolvedValue({
keyB64: "hashedPassword",
} as any);
const [send] = await sendService.encrypt(sendView, null, "password123");
expect(send.emails).toBeNull();
expect(send.password).toBe("hashedPassword");
});
});
});
describe("when SendEmailOTP feature flag is OFF", () => {
beforeEach(() => {
configService.getFeatureFlag.mockResolvedValue(false);
cryptoFunctionService.hash.mockClear();
});
it("should NOT encrypt emails even when provided", async () => {
sendView.emails = ["test@example.com"];
const [send] = await sendService.encrypt(sendView, null, null);
expect(send.emails).toBeNull();
expect(send.emailHashes).toBe("");
expect(cryptoFunctionService.hash).not.toHaveBeenCalled();
});
it("should use password when provided and flag is OFF", async () => {
sendView.emails = [];
keyGenerationService.deriveKeyFromPassword.mockResolvedValue({
keyB64: "hashedPassword",
} as any);
const [send] = await sendService.encrypt(sendView, null, "password123");
expect(send.emails).toBeNull();
expect(send.emailHashes).toBe("");
expect(send.password).toBe("hashedPassword");
});
it("should ignore emails and use password when both provided", async () => {
sendView.emails = ["test@example.com"];
keyGenerationService.deriveKeyFromPassword.mockResolvedValue({
keyB64: "hashedPassword",
} as any);
const [send] = await sendService.encrypt(sendView, null, "password123");
expect(send.emails).toBeNull();
expect(send.emailHashes).toBe("");
expect(send.password).toBe("hashedPassword");
expect(cryptoFunctionService.hash).not.toHaveBeenCalled();
});
it("should set emails and password to null when neither provided", async () => {
sendView.emails = [];
const [send] = await sendService.encrypt(sendView, null, null);
expect(send.emails).toBeNull();
expect(send.emailHashes).toBe("");
expect(send.password).toBeUndefined();
});
});
describe("null handling for name and notes", () => {
it("should handle null name correctly", async () => {
sendView.name = null;
sendView.emails = [];
const [send] = await sendService.encrypt(sendView, null, null);
expect(send.name).toBeNull();
});
it("should handle null notes correctly", async () => {
sendView.notes = null;
sendView.emails = [];
const [send] = await sendService.encrypt(sendView, null, null);
expect(send.notes).toBeNull();
});
it("should encrypt non-null name and notes", async () => {
sendView.name = "Test Name";
sendView.notes = "Test Notes";
sendView.emails = [];
const [send] = await sendService.encrypt(sendView, null, null);
expect(encryptService.encryptString).toHaveBeenCalledWith("Test Name", mockCryptoKey);
expect(encryptService.encryptString).toHaveBeenCalledWith("Test Notes", mockCryptoKey);
expect(send.name).toEqual({ encryptedString: "encrypted" });
expect(send.notes).toEqual({ encryptedString: "encrypted" });
});
});
});
});

View File

@@ -7,9 +7,12 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
// eslint-disable-next-line no-restricted-imports
import { PBKDF2KdfConfig, KeyService } from "@bitwarden/key-management";
import { FeatureFlag } from "../../../enums/feature-flag.enum";
import { KeyGenerationService } from "../../../key-management/crypto";
import { CryptoFunctionService } from "../../../key-management/crypto/abstractions/crypto-function.service";
import { EncryptService } from "../../../key-management/crypto/abstractions/encrypt.service";
import { EncString } from "../../../key-management/crypto/models/enc-string";
import { ConfigService } from "../../../platform/abstractions/config/config.service";
import { I18nService } from "../../../platform/abstractions/i18n.service";
import { Utils } from "../../../platform/misc/utils";
import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer";
@@ -51,6 +54,8 @@ export class SendService implements InternalSendServiceAbstraction {
private keyGenerationService: KeyGenerationService,
private stateProvider: SendStateProvider,
private encryptService: EncryptService,
private cryptoFunctionService: CryptoFunctionService,
private configService: ConfigService,
) {}
async encrypt(
@@ -80,19 +85,30 @@ export class SendService implements InternalSendServiceAbstraction {
model.cryptoKey = key.derivedKey;
}
// Check feature flag for email OTP authentication
const sendEmailOTPEnabled = await this.configService.getFeatureFlag(FeatureFlag.SendEmailOTP);
const hasEmails = (model.emails?.length ?? 0) > 0;
if (hasEmails) {
send.emails = model.emails.join(",");
if (sendEmailOTPEnabled && hasEmails) {
const plaintextEmails = model.emails.join(",");
send.emails = await this.encryptService.encryptString(plaintextEmails, model.cryptoKey);
send.emailHashes = await this.hashEmails(plaintextEmails);
send.password = null;
} else if (password != null) {
// Note: Despite being called key, the passwordKey is not used for encryption.
// It is used as a static proof that the client knows the password, and has the encryption key.
const passwordKey = await this.keyGenerationService.deriveKeyFromPassword(
password,
model.key,
new PBKDF2KdfConfig(SEND_KDF_ITERATIONS),
);
send.password = passwordKey.keyB64;
} else {
send.emails = null;
send.emailHashes = "";
if (password != null) {
// Note: Despite being called key, the passwordKey is not used for encryption.
// It is used as a static proof that the client knows the password, and has the encryption key.
const passwordKey = await this.keyGenerationService.deriveKeyFromPassword(
password,
model.key,
new PBKDF2KdfConfig(SEND_KDF_ITERATIONS),
);
send.password = passwordKey.keyB64;
}
}
const userId = (await firstValueFrom(this.accountService.activeAccount$)).id;
if (userKey == null) {
@@ -100,10 +116,14 @@ export class SendService implements InternalSendServiceAbstraction {
}
// Key is not a SymmetricCryptoKey, but key material used to derive the cryptoKey
send.key = await this.encryptService.encryptBytes(model.key, userKey);
// FIXME: model.name can be null. encryptString should not be called with null values.
send.name = await this.encryptService.encryptString(model.name, model.cryptoKey);
// FIXME: model.notes can be null. encryptString should not be called with null values.
send.notes = await this.encryptService.encryptString(model.notes, model.cryptoKey);
send.name =
model.name != null
? await this.encryptService.encryptString(model.name, model.cryptoKey)
: null;
send.notes =
model.notes != null
? await this.encryptService.encryptString(model.notes, model.cryptoKey)
: null;
if (send.type === SendType.Text) {
send.text = new SendText();
// FIXME: model.text.text can be null. encryptString should not be called with null values.
@@ -127,6 +147,8 @@ export class SendService implements InternalSendServiceAbstraction {
}
}
send.authType = model.authType;
return [send, fileData];
}
@@ -371,4 +393,19 @@ export class SendService implements InternalSendServiceAbstraction {
decryptedSends.sort(Utils.getSortFunction(this.i18nService, "name"));
return decryptedSends;
}
private async hashEmails(emails: string): Promise<string> {
if (!emails) {
return "";
}
const emailArray = emails.split(",").map((e) => e.trim().toLowerCase());
const hashPromises = emailArray.map(async (email) => {
const hash: Uint8Array = await this.cryptoFunctionService.hash(email, "sha256");
return Utils.fromBufferToHex(hash).toUpperCase();
});
const hashes = await Promise.all(hashPromises);
return hashes.join(",");
}
}

View File

@@ -20,6 +20,7 @@ export function testSendViewData(id: string, name: string) {
data.deletionDate = null;
data.notes = "Notes!!";
data.key = null;
data.emails = [];
return data;
}
@@ -39,6 +40,8 @@ export function createSendData(value: Partial<SendData> = {}) {
expirationDate: "2024-09-04",
deletionDate: "2024-09-04",
password: "password",
emails: "",
emailHashes: "",
disabled: false,
hideEmail: false,
};
@@ -62,6 +65,8 @@ export function testSendData(id: string, name: string) {
data.deletionDate = null;
data.notes = "Notes!!";
data.key = null;
data.emails = "";
data.emailHashes = "";
return data;
}
@@ -77,5 +82,7 @@ export function testSend(id: string, name: string) {
data.deletionDate = null;
data.notes = new EncString("Notes!!");
data.key = null;
data.emails = null;
data.emailHashes = "";
return data;
}