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