diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 660fcb97bcf..8d741039b31 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -1031,6 +1031,8 @@ export default class MainBackground { this.keyGenerationService, this.sendStateProvider, this.encryptService, + this.cryptoFunctionService, + this.configService, ); this.sendApiService = new SendApiService( this.apiService, diff --git a/apps/cli/src/register-oss-programs.ts b/apps/cli/src/register-oss-programs.ts index 71d7aaa0d52..f0b0475c808 100644 --- a/apps/cli/src/register-oss-programs.ts +++ b/apps/cli/src/register-oss-programs.ts @@ -18,5 +18,5 @@ export async function registerOssPrograms(serviceContainer: ServiceContainer) { await vaultProgram.register(); const sendProgram = new SendProgram(serviceContainer); - sendProgram.register(); + await sendProgram.register(); } diff --git a/apps/cli/src/service-container/service-container.ts b/apps/cli/src/service-container/service-container.ts index 7bb8da27040..3e78eb36577 100644 --- a/apps/cli/src/service-container/service-container.ts +++ b/apps/cli/src/service-container/service-container.ts @@ -608,6 +608,8 @@ export class ServiceContainer { this.keyGenerationService, this.sendStateProvider, this.encryptService, + this.cryptoFunctionService, + this.configService, ); this.cipherFileUploadService = new CipherFileUploadService( diff --git a/apps/cli/src/tools/send/commands/create.command.spec.ts b/apps/cli/src/tools/send/commands/create.command.spec.ts new file mode 100644 index 00000000000..d3702689812 --- /dev/null +++ b/apps/cli/src/tools/send/commands/create.command.spec.ts @@ -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(); + const environmentService = mock(); + const sendApiService = mock(); + const accountProfileService = mock(); + const accountService = mock(); + + 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); + }); + }); + }); +}); diff --git a/apps/cli/src/tools/send/commands/create.command.ts b/apps/cli/src/tools/send/commands/create.command.ts index 91e579c26c1..ad4ff9c4e18 100644 --- a/apps/cli/src/tools/send/commands/create.command.ts +++ b/apps/cli/src/tools/send/commands/create.command.ts @@ -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)); diff --git a/apps/cli/src/tools/send/commands/edit.command.spec.ts b/apps/cli/src/tools/send/commands/edit.command.spec.ts new file mode 100644 index 00000000000..5bac63d3821 --- /dev/null +++ b/apps/cli/src/tools/send/commands/edit.command.spec.ts @@ -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(); + const getCommand = mock(); + const sendApiService = mock(); + const accountProfileService = mock(); + const accountService = mock(); + + 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."); + }); + }); +}); diff --git a/apps/cli/src/tools/send/commands/edit.command.ts b/apps/cli/src/tools/send/commands/edit.command.ts index 2c6d41d66ac..0709a33b88f 100644 --- a/apps/cli/src/tools/send/commands/edit.command.ts +++ b/apps/cli/src/tools/send/commands/edit.command.ts @@ -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); diff --git a/apps/cli/src/tools/send/models/send.response.ts b/apps/cli/src/tools/send/models/send.response.ts index b7655226be0..c8182cbfaf8 100644 --- a/apps/cli/src/tools/send/models/send.response.ts +++ b/apps/cli/src/tools/send/models/send.response.ts @@ -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; 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); diff --git a/apps/cli/src/tools/send/send.program.ts b/apps/cli/src/tools/send/send.program.ts index 869d77a379c..a84b6c15ead 100644 --- a/apps/cli/src/tools/send/send.program.ts +++ b/apps/cli/src/tools/send/send.program.ts @@ -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("", "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 ", "optional emails to access this Send. Can also be specified in JSON.", - ) - .argParser(parseEmail) - .hideHelp(), + ).argParser(parseEmail), ) .option("-a, --maxAccessCount ", "The amount of max possible accesses.") .option("--hidden", "Hide 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"); diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 1ecf7fe3e3d..5a582626e68 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -858,6 +858,8 @@ const safeProviders: SafeProvider[] = [ KeyGenerationService, SendStateProviderAbstraction, EncryptService, + CryptoFunctionServiceAbstraction, + ConfigService, ], }), safeProvider({ diff --git a/libs/common/src/tools/send/models/data/send.data.ts b/libs/common/src/tools/send/models/data/send.data.ts index 7eeb15f3ebe..4081eba2878 100644 --- a/libs/common/src/tools/send/models/data/send.data.ts +++ b/libs/common/src/tools/send/models/data/send.data.ts @@ -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: diff --git a/libs/common/src/tools/send/models/domain/send.spec.ts b/libs/common/src/tools/send/models/domain/send.spec.ts index cd51390908e..f660333c917 100644 --- a/libs/common/src/tools/send/models/domain/send.spec.ts +++ b/libs/common/src/tools/send/models/domain/send.spec.ts @@ -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(); const keyService = mock(); 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; + let keyService: jest.Mocked; + const userKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey; + const userId = emptyGuid as UserId; + + beforeEach(() => { + encryptService = mock(); + keyService = mock(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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; + let keyService: jest.Mocked; + const userKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey; + const userId = emptyGuid as UserId; + + beforeEach(() => { + encryptService = mock(); + keyService = mock(); + 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(); + 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(); + 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(); + 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"); }); }); }); diff --git a/libs/common/src/tools/send/models/domain/send.ts b/libs/common/src/tools/send/models/domain/send.ts index 82c37a17528..5247d35c655 100644 --- a/libs/common/src/tools/send/models/domain/send.ts +++ b/libs/common/src/tools/send/models/domain/send.ts @@ -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(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, diff --git a/libs/common/src/tools/send/models/request/send.request.spec.ts b/libs/common/src/tools/send/models/request/send.request.spec.ts new file mode 100644 index 00000000000..1daee1d01ff --- /dev/null +++ b/libs/common/src/tools/send/models/request/send.request.spec.ts @@ -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); + }); + }); +}); diff --git a/libs/common/src/tools/send/models/request/send.request.ts b/libs/common/src/tools/send/models/request/send.request.ts index 902ca0a2c54..37590e40108 100644 --- a/libs/common/src/tools/send/models/request/send.request.ts +++ b/libs/common/src/tools/send/models/request/send.request.ts @@ -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; diff --git a/libs/common/src/tools/send/models/response/send.response.ts b/libs/common/src/tools/send/models/response/send.response.ts index 7a7885d5ae1..a51b1e8ac7a 100644 --- a/libs/common/src/tools/send/models/response/send.response.ts +++ b/libs/common/src/tools/send/models/response/send.response.ts @@ -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) { diff --git a/libs/common/src/tools/send/models/view/send.view.ts b/libs/common/src/tools/send/models/view/send.view.ts index d07de6d8293..150a649671b 100644 --- a/libs/common/src/tools/send/models/view/send.view.ts +++ b/libs/common/src/tools/send/models/view/send.view.ts @@ -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 { diff --git a/libs/common/src/tools/send/services/send-api.service.ts b/libs/common/src/tools/send/services/send-api.service.ts index f09117316d8..57004b6ff0e 100644 --- a/libs/common/src/tools/send/services/send-api.service.ts +++ b/libs/common/src/tools/send/services/send-api.service.ts @@ -189,6 +189,7 @@ export class SendApiService implements SendApiServiceAbstraction { private async upload(sendData: [Send, EncArrayBuffer]): Promise { const request = new SendRequest(sendData[0], sendData[1]?.buffer.byteLength); + let response: SendResponse; if (sendData[0].id == null) { if (sendData[0].type === SendType.Text) { diff --git a/libs/common/src/tools/send/services/send.service.spec.ts b/libs/common/src/tools/send/services/send.service.spec.ts index fb99ddbe3bc..1c587327098 100644 --- a/libs/common/src/tools/send/services/send.service.spec.ts +++ b/libs/common/src/tools/send/services/send.service.spec.ts @@ -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(); const encryptService = mock(); const environmentService = mock(); - + const cryptoFunctionService = mock(); + const configService = mock(); 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" }); + }); + }); + }); }); diff --git a/libs/common/src/tools/send/services/send.service.ts b/libs/common/src/tools/send/services/send.service.ts index c274d90146e..078e94b2563 100644 --- a/libs/common/src/tools/send/services/send.service.ts +++ b/libs/common/src/tools/send/services/send.service.ts @@ -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 { + 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(","); + } } diff --git a/libs/common/src/tools/send/services/test-data/send-tests.data.ts b/libs/common/src/tools/send/services/test-data/send-tests.data.ts index c1d04ab2926..9c4e121edc0 100644 --- a/libs/common/src/tools/send/services/test-data/send-tests.data.ts +++ b/libs/common/src/tools/send/services/test-data/send-tests.data.ts @@ -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 = {}) { 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; }