1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-13 06:54:07 +00:00

[PM-23108] CLI Add Email Verification to Send Receive (#18649)

This commit is contained in:
John Harrington
2026-02-11 14:44:49 -07:00
committed by GitHub
parent f8976f992a
commit d7cca1bedf
4 changed files with 943 additions and 50 deletions

View File

@@ -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,

View File

@@ -0,0 +1,560 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { mock } from "jest-mock-extended";
import { of } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { SendTokenService, SendAccessToken } from "@bitwarden/common/auth/send-access";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SendAccessResponse } from "@bitwarden/common/tools/send/models/response/send-access.response";
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
import { KeyService } from "@bitwarden/key-management";
import { Response } from "../../../models/response";
import { SendReceiveCommand } from "./receive.command";
describe("SendReceiveCommand", () => {
let command: SendReceiveCommand;
const keyService = mock<KeyService>();
const encryptService = mock<EncryptService>();
const cryptoFunctionService = mock<CryptoFunctionService>();
const platformUtilsService = mock<PlatformUtilsService>();
const environmentService = mock<EnvironmentService>();
const sendApiService = mock<SendApiService>();
const apiService = mock<ApiService>();
const sendTokenService = mock<SendTokenService>();
const configService = mock<ConfigService>();
const testUrl = "https://send.bitwarden.com/#/send/abc123/key456";
const testSendId = "abc123";
beforeEach(() => {
jest.clearAllMocks();
environmentService.environment$ = of({
getUrls: () => ({
api: "https://api.bitwarden.com",
webVault: "https://vault.bitwarden.com",
}),
} as any);
platformUtilsService.isDev.mockReturnValue(false);
keyService.makeSendKey.mockResolvedValue({} as any);
cryptoFunctionService.pbkdf2.mockResolvedValue(new Uint8Array(32));
command = new SendReceiveCommand(
keyService,
encryptService,
cryptoFunctionService,
platformUtilsService,
environmentService,
sendApiService,
apiService,
sendTokenService,
configService,
);
});
describe("URL parsing", () => {
it("should return error for invalid URL", async () => {
const response = await command.run("not-a-valid-url", {});
expect(response.success).toBe(false);
expect(response.message).toContain("Failed to parse");
});
it("should return error when URL is missing send ID or key", async () => {
configService.getFeatureFlag.mockResolvedValue(false);
const response = await command.run("https://send.bitwarden.com/#/send/", {});
expect(response.success).toBe(false);
expect(response.message).toContain("not a valid Send url");
});
});
describe("V1 Flow (Feature Flag Off)", () => {
beforeEach(() => {
configService.getFeatureFlag.mockResolvedValue(false);
});
it("should successfully access unprotected Send", async () => {
const mockSendAccess = {
id: testSendId,
type: SendType.Text,
text: { text: "secret message" },
};
sendApiService.postSendAccess.mockResolvedValue({} as any);
jest.spyOn(command as any, "sendRequest").mockResolvedValue(mockSendAccess);
const response = await command.run(testUrl, {});
expect(response.success).toBe(true);
});
it("should successfully access password-protected Send with --password option", async () => {
const mockSendAccess = {
id: testSendId,
type: SendType.Text,
text: { text: "secret message" },
};
sendApiService.postSendAccess.mockResolvedValue({} as any);
jest.spyOn(command as any, "sendRequest").mockResolvedValue(mockSendAccess);
const response = await command.run(testUrl, { password: "test-password" });
expect(response.success).toBe(true);
expect(cryptoFunctionService.pbkdf2).toHaveBeenCalledWith(
"test-password",
expect.any(Uint8Array),
"sha256",
100000,
);
});
it("should return error for incorrect password in non-interactive mode", async () => {
process.env.BW_NOINTERACTION = "true";
const error = new ErrorResponse(
{
statusCode: 401,
message: "Unauthorized",
},
401,
);
sendApiService.postSendAccess.mockRejectedValue(error);
const response = await command.run(testUrl, { password: "wrong-password" });
expect(response.success).toBe(false);
expect(response.message).toContain("Incorrect or missing password");
delete process.env.BW_NOINTERACTION;
});
it("should return 404 for non-existent Send", async () => {
const error = new ErrorResponse(
{
statusCode: 404,
message: "Not found",
},
404,
);
sendApiService.postSendAccess.mockRejectedValue(error);
const response = await command.run(testUrl, {});
expect(response.success).toBe(false);
});
});
describe("V2 Flow (Feature Flag On)", () => {
beforeEach(() => {
configService.getFeatureFlag.mockResolvedValue(true);
});
describe("Unprotected Sends", () => {
it("should successfully access Send with cached token", async () => {
const mockToken = new SendAccessToken("test-token", Date.now() + 3600000);
sendTokenService.tryGetSendAccessToken$.mockReturnValue(of(mockToken));
sendApiService.postSendAccessV2.mockResolvedValue({} as any);
jest.spyOn(command as any, "accessSendWithToken").mockResolvedValue(Response.success());
const response = await command.run(testUrl, {});
expect(response.success).toBe(true);
expect(sendTokenService.tryGetSendAccessToken$).toHaveBeenCalledWith(testSendId);
});
it("should handle expired token and determine auth type", async () => {
sendTokenService.tryGetSendAccessToken$.mockReturnValue(
of({
kind: "expected_server",
error: {
error: "invalid_request",
send_access_error_type: "password_hash_b64_required",
},
} as any),
);
// Mock password auth flow
const mockToken = new SendAccessToken("test-token", Date.now() + 3600000);
sendTokenService.getSendAccessToken$.mockReturnValue(of(mockToken));
jest.spyOn(command as any, "accessSendWithToken").mockResolvedValue(Response.success());
const response = await command.run(testUrl, { password: "test-password" });
expect(response.success).toBe(true);
});
});
describe("Password Authentication (V2)", () => {
it("should successfully authenticate with password", async () => {
sendTokenService.tryGetSendAccessToken$.mockReturnValue(
of({
kind: "expected_server",
error: {
error: "invalid_request",
send_access_error_type: "password_hash_b64_required",
},
} as any),
);
const mockToken = new SendAccessToken("test-token", Date.now() + 3600000);
sendTokenService.getSendAccessToken$.mockReturnValue(of(mockToken));
sendApiService.postSendAccessV2.mockResolvedValue({} as any);
jest.spyOn(command as any, "accessSendWithToken").mockResolvedValue(Response.success());
const response = await command.run(testUrl, { password: "correct-password" });
expect(response.success).toBe(true);
expect(sendTokenService.getSendAccessToken$).toHaveBeenCalledWith(
testSendId,
expect.objectContaining({
kind: "password",
passwordHashB64: expect.any(String),
}),
);
});
it("should return error for invalid password", async () => {
process.env.BW_NOINTERACTION = "true";
sendTokenService.tryGetSendAccessToken$.mockReturnValue(
of({
kind: "expected_server",
error: {
error: "invalid_request",
send_access_error_type: "password_hash_b64_required",
},
} as any),
);
sendTokenService.getSendAccessToken$.mockReturnValue(
of({
kind: "expected_server",
error: {
error: "invalid_grant",
send_access_error_type: "password_hash_b64_invalid",
},
} as any),
);
const response = await command.run(testUrl, { password: "wrong-password" });
expect(response.success).toBe(false);
expect(response.message).toContain("Invalid password");
delete process.env.BW_NOINTERACTION;
});
it("should work with --passwordenv option", async () => {
process.env.TEST_SEND_PASSWORD = "env-password";
process.env.BW_NOINTERACTION = "true";
sendTokenService.tryGetSendAccessToken$.mockReturnValue(
of({
kind: "expected_server",
error: {
error: "invalid_request",
send_access_error_type: "password_hash_b64_required",
},
} as any),
);
const mockToken = new SendAccessToken("test-token", Date.now() + 3600000);
sendTokenService.getSendAccessToken$.mockReturnValue(of(mockToken));
jest.spyOn(command as any, "accessSendWithToken").mockResolvedValue(Response.success());
const response = await command.run(testUrl, { passwordenv: "TEST_SEND_PASSWORD" });
expect(response.success).toBe(true);
delete process.env.TEST_SEND_PASSWORD;
delete process.env.BW_NOINTERACTION;
});
});
describe("Email OTP Authentication (V2)", () => {
it("should return error in non-interactive mode for email OTP", async () => {
process.env.BW_NOINTERACTION = "true";
sendTokenService.tryGetSendAccessToken$.mockReturnValue(
of({
kind: "expected_server",
error: {
error: "invalid_request",
send_access_error_type: "email_required",
},
} as any),
);
const response = await command.run(testUrl, {});
expect(response.success).toBe(false);
expect(response.message).toContain("Email verification required");
expect(response.message).toContain("interactive mode");
delete process.env.BW_NOINTERACTION;
});
it("should handle email submission and OTP prompt flow", async () => {
sendTokenService.tryGetSendAccessToken$.mockReturnValue(
of({
kind: "expected_server",
error: {
error: "invalid_request",
send_access_error_type: "email_required",
},
} as any),
);
sendTokenService.getSendAccessToken$.mockReturnValueOnce(
of({
kind: "expected_server",
error: {
error: "invalid_request",
send_access_error_type: "email_and_otp_required_otp_sent",
},
} as any),
);
const mockToken = new SendAccessToken("test-token", Date.now() + 3600000);
sendTokenService.getSendAccessToken$.mockReturnValueOnce(of(mockToken));
// We can't easily test the interactive prompts, but we can verify the token service calls
// would be made in the right order
expect(sendTokenService.getSendAccessToken$).toBeDefined();
});
it("should handle invalid email error", async () => {
sendTokenService.tryGetSendAccessToken$.mockReturnValue(
of({
kind: "expected_server",
error: {
error: "invalid_request",
send_access_error_type: "email_required",
},
} as any),
);
sendTokenService.getSendAccessToken$.mockReturnValue(
of({
kind: "expected_server",
error: {
error: "invalid_grant",
send_access_error_type: "email_invalid",
},
} as any),
);
// In a real scenario with interactive prompts, this would retry
// For unit tests, we verify the error is recognized
expect(sendTokenService.getSendAccessToken$).toBeDefined();
});
it("should handle invalid OTP error", async () => {
sendTokenService.getSendAccessToken$.mockReturnValue(
of({
kind: "expected_server",
error: {
error: "invalid_grant",
send_access_error_type: "otp_invalid",
},
} as any),
);
// Verify OTP validation would be handled
expect(sendTokenService.getSendAccessToken$).toBeDefined();
});
});
describe("File Downloads (V2)", () => {
it("should successfully download file Send with V2 API", async () => {
const mockToken = new SendAccessToken("test-token", Date.now() + 3600000);
sendTokenService.tryGetSendAccessToken$.mockReturnValue(of(mockToken));
const mockSendResponse = {
id: testSendId,
type: SendType.File,
file: {
id: "file-123",
fileName: "test.pdf",
size: 1024,
},
};
sendApiService.postSendAccessV2.mockResolvedValue(mockSendResponse as any);
sendApiService.getSendFileDownloadDataV2.mockResolvedValue({
url: "https://example.com/download",
} as any);
encryptService.decryptFileData.mockResolvedValue(new ArrayBuffer(1024) as any);
jest.spyOn(command as any, "saveAttachmentToFile").mockResolvedValue(Response.success());
await command.run(testUrl, { output: "./test.pdf" });
expect(sendApiService.getSendFileDownloadDataV2).toHaveBeenCalledWith(
expect.any(Object),
mockToken,
"https://api.bitwarden.com",
);
});
});
describe("Invalid Send ID", () => {
it("should return 404 for invalid Send ID", async () => {
sendTokenService.tryGetSendAccessToken$.mockReturnValue(
of({
kind: "expected_server",
error: {
error: "invalid_grant",
send_access_error_type: "send_id_invalid",
},
} as any),
);
const response = await command.run(testUrl, {});
expect(response.success).toBe(false);
});
});
describe("Text Send Output", () => {
it("should output text to stdout for text Sends", async () => {
const mockToken = new SendAccessToken("test-token", Date.now() + 3600000);
sendTokenService.tryGetSendAccessToken$.mockReturnValue(of(mockToken));
const secretText = "This is a secret message";
sendApiService.postSendAccessV2.mockResolvedValue({} as any);
// Mock the entire accessSendWithToken to avoid encryption issues
jest.spyOn(command as any, "accessSendWithToken").mockImplementation(async () => {
process.stdout.write(secretText);
return Response.success();
});
const stdoutSpy = jest.spyOn(process.stdout, "write").mockImplementation(() => true);
const response = await command.run(testUrl, {});
expect(response.success).toBe(true);
expect(stdoutSpy).toHaveBeenCalledWith(secretText);
stdoutSpy.mockRestore();
});
it("should return JSON object when --obj flag is used", async () => {
const mockToken = new SendAccessToken("test-token", Date.now() + 3600000);
sendTokenService.tryGetSendAccessToken$.mockReturnValue(of(mockToken));
const mockDecryptedView = {
id: testSendId,
type: SendType.Text,
text: { text: "secret message" },
};
sendApiService.postSendAccessV2.mockResolvedValue({} as any);
// Mock the entire accessSendWithToken to avoid encryption issues
jest.spyOn(command as any, "accessSendWithToken").mockImplementation(async () => {
const sendAccessResponse = new SendAccessResponse(mockDecryptedView as any);
const res = new Response();
res.success = true;
res.data = sendAccessResponse as any;
return res;
});
const response = await command.run(testUrl, { obj: true });
expect(response.success).toBe(true);
expect(response.data).toBeDefined();
expect(response.data.constructor.name).toBe("SendAccessResponse");
});
});
});
describe("API URL Resolution", () => {
it("should resolve send.bitwarden.com to api.bitwarden.com", async () => {
configService.getFeatureFlag.mockResolvedValue(false);
const sendUrl = "https://send.bitwarden.com/#/send/abc123/key456";
sendApiService.postSendAccess.mockResolvedValue({} as any);
jest.spyOn(command as any, "sendRequest").mockResolvedValue({
type: SendType.Text,
text: { text: "test" },
});
await command.run(sendUrl, {});
const apiUrl = await (command as any).getApiUrl(new URL(sendUrl));
expect(apiUrl).toBe("https://api.bitwarden.com");
});
it("should handle custom domain URLs", async () => {
configService.getFeatureFlag.mockResolvedValue(false);
const customUrl = "https://custom.example.com/#/send/abc123/key456";
sendApiService.postSendAccess.mockResolvedValue({} as any);
jest.spyOn(command as any, "sendRequest").mockResolvedValue({
type: SendType.Text,
text: { text: "test" },
});
await command.run(customUrl, {});
const apiUrl = await (command as any).getApiUrl(new URL(customUrl));
expect(apiUrl).toBe("https://custom.example.com/api");
});
});
describe("Feature Flag Routing", () => {
it("should route to V1 flow when feature flag is off", async () => {
configService.getFeatureFlag.mockResolvedValue(false);
sendApiService.postSendAccess.mockResolvedValue({} as any);
const v1Spy = jest.spyOn(command as any, "attemptV1Access");
jest.spyOn(command as any, "sendRequest").mockResolvedValue({
type: SendType.Text,
text: { text: "test" },
});
await command.run(testUrl, {});
expect(configService.getFeatureFlag).toHaveBeenCalledWith(FeatureFlag.SendEmailOTP);
expect(v1Spy).toHaveBeenCalled();
});
it("should route to V2 flow when feature flag is on", async () => {
configService.getFeatureFlag.mockResolvedValue(true);
const mockToken = new SendAccessToken("test-token", Date.now() + 3600000);
sendTokenService.tryGetSendAccessToken$.mockReturnValue(of(mockToken));
const v2Spy = jest.spyOn(command as any, "attemptV2Access");
jest.spyOn(command as any, "accessSendWithToken").mockResolvedValue(Response.success());
await command.run(testUrl, {});
expect(configService.getFeatureFlag).toHaveBeenCalledWith(FeatureFlag.SendEmailOTP);
expect(v2Spy).toHaveBeenCalled();
});
});
});

View File

@@ -5,9 +5,25 @@ import * as inquirer from "inquirer";
import { firstValueFrom } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import {
SendTokenService,
SendAccessToken,
emailRequired,
emailAndOtpRequired,
otpInvalid,
passwordHashB64Required,
passwordHashB64Invalid,
sendIdInvalid,
SendHashedPasswordB64,
SendOtp,
GetSendAccessTokenError,
SendAccessDomainCredentials,
} from "@bitwarden/common/auth/send-access";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
@@ -17,6 +33,7 @@ import { SendAccess } from "@bitwarden/common/tools/send/models/domain/send-acce
import { SendAccessRequest } from "@bitwarden/common/tools/send/models/request/send-access.request";
import { SendAccessView } from "@bitwarden/common/tools/send/models/view/send-access.view";
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
import { AuthType } from "@bitwarden/common/tools/send/types/auth-type";
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
import { KeyService } from "@bitwarden/key-management";
import { NodeUtils } from "@bitwarden/node/node-utils";
@@ -38,6 +55,8 @@ export class SendReceiveCommand extends DownloadCommand {
private environmentService: EnvironmentService,
private sendApiService: SendApiService,
apiService: ApiService,
private sendTokenService: SendTokenService,
private configService: ConfigService,
) {
super(encryptService, apiService);
}
@@ -62,58 +81,13 @@ export class SendReceiveCommand extends DownloadCommand {
}
const keyArray = Utils.fromUrlB64ToArray(key);
this.sendAccessRequest = new SendAccessRequest();
let password = options.password;
if (password == null || password === "") {
if (options.passwordfile) {
password = await NodeUtils.readFirstLine(options.passwordfile);
} else if (options.passwordenv && process.env[options.passwordenv]) {
password = process.env[options.passwordenv];
}
}
const sendEmailOtpEnabled = await this.configService.getFeatureFlag(FeatureFlag.SendEmailOTP);
if (password != null && password !== "") {
this.sendAccessRequest.password = await this.getUnlockedPassword(password, keyArray);
}
const response = await this.sendRequest(apiUrl, id, keyArray);
if (response instanceof Response) {
// Error scenario
return response;
}
if (options.obj != null) {
return Response.success(new SendAccessResponse(response));
}
switch (response.type) {
case SendType.Text:
// Write to stdout and response success so we get the text string only to stdout
process.stdout.write(response?.text?.text);
return Response.success();
case SendType.File: {
const downloadData = await this.sendApiService.getSendFileDownloadData(
response,
this.sendAccessRequest,
apiUrl,
);
const decryptBufferFn = async (resp: globalThis.Response) => {
const encBuf = await EncArrayBuffer.fromResponse(resp);
return this.encryptService.decryptFileData(encBuf, this.decKey);
};
return await this.saveAttachmentToFile(
downloadData.url,
response?.file?.fileName,
decryptBufferFn,
options.output,
);
}
default:
return Response.success(new SendAccessResponse(response));
if (sendEmailOtpEnabled) {
return await this.attemptV2Access(apiUrl, id, keyArray, options);
} else {
return await this.attemptV1Access(apiUrl, id, keyArray, options);
}
}
@@ -146,6 +120,350 @@ export class SendReceiveCommand extends DownloadCommand {
return Utils.fromBufferToB64(passwordHash);
}
private async attemptV1Access(
apiUrl: string,
id: string,
keyArray: Uint8Array,
options: OptionValues,
): Promise<Response> {
this.sendAccessRequest = new SendAccessRequest();
let password = options.password;
if (password == null || password === "") {
if (options.passwordfile) {
password = await NodeUtils.readFirstLine(options.passwordfile);
} else if (options.passwordenv && process.env[options.passwordenv]) {
password = process.env[options.passwordenv];
}
}
if (password != null && password !== "") {
this.sendAccessRequest.password = await this.getUnlockedPassword(password, keyArray);
}
const response = await this.sendRequest(apiUrl, id, keyArray);
if (response instanceof Response) {
return response;
}
if (options.obj != null) {
return Response.success(new SendAccessResponse(response));
}
switch (response.type) {
case SendType.Text:
process.stdout.write(response?.text?.text);
return Response.success();
case SendType.File: {
const downloadData = await this.sendApiService.getSendFileDownloadData(
response,
this.sendAccessRequest,
apiUrl,
);
const decryptBufferFn = async (resp: globalThis.Response) => {
const encBuf = await EncArrayBuffer.fromResponse(resp);
return this.encryptService.decryptFileData(encBuf, this.decKey);
};
return await this.saveAttachmentToFile(
downloadData.url,
response?.file?.fileName,
decryptBufferFn,
options.output,
);
}
default:
return Response.success(new SendAccessResponse(response));
}
}
private async attemptV2Access(
apiUrl: string,
id: string,
keyArray: Uint8Array,
options: OptionValues,
): Promise<Response> {
let authType: AuthType = AuthType.None;
const currentResponse = await this.getTokenWithRetry(id);
if (currentResponse instanceof SendAccessToken) {
return await this.accessSendWithToken(currentResponse, keyArray, apiUrl, options);
}
if (currentResponse.kind === "expected_server") {
const error = currentResponse.error;
if (emailRequired(error)) {
authType = AuthType.Email;
} else if (passwordHashB64Required(error)) {
authType = AuthType.Password;
} else if (sendIdInvalid(error)) {
return Response.notFound();
}
} else {
return this.handleError(currentResponse);
}
// Handle authentication based on type
if (authType === AuthType.Email) {
if (!this.canInteract) {
return Response.badRequest("Email verification required. Run in interactive mode.");
}
return await this.handleEmailOtpAuth(id, keyArray, apiUrl, options);
} else if (authType === AuthType.Password) {
return await this.handlePasswordAuth(id, keyArray, apiUrl, options);
}
// The auth layer will immediately return a token for Sends with AuthType.None
// If this code is reached, something has gone wrong
if (authType === AuthType.None) {
return Response.error("Could not determine authentication requirements");
}
return Response.error("Authentication failed");
}
private async getTokenWithRetry(
sendId: string,
credentials?: SendAccessDomainCredentials,
): Promise<SendAccessToken | GetSendAccessTokenError> {
let expiredAttempts = 0;
while (expiredAttempts < 3) {
const response = credentials
? await firstValueFrom(this.sendTokenService.getSendAccessToken$(sendId, credentials))
: await firstValueFrom(this.sendTokenService.tryGetSendAccessToken$(sendId));
if (response instanceof SendAccessToken) {
return response;
}
if (response.kind === "expired") {
expiredAttempts++;
continue;
}
// Not expired, return the response for caller to handle
return response;
}
// After 3 expired attempts, return an error response
return {
kind: "unknown",
error: "Send access token has expired and could not be refreshed",
};
}
private handleError(error: GetSendAccessTokenError): Response {
if (error.kind === "unexpected_server") {
return Response.error("Server error: " + JSON.stringify(error.error));
}
return Response.error("Error: " + JSON.stringify(error.error));
}
private async promptForOtp(sendId: string, email: string): Promise<SendOtp> {
const otpAnswer = await inquirer.createPromptModule({ output: process.stderr })({
type: "input",
name: "otp",
message: "Enter the verification code sent to your email:",
});
return otpAnswer.otp;
}
private async promptForEmail(): Promise<string> {
const emailAnswer = await inquirer.createPromptModule({ output: process.stderr })({
type: "input",
name: "email",
message: "Enter your email address:",
validate: (input: string) => {
if (!input || !input.includes("@")) {
return "Please enter a valid email address";
}
return true;
},
});
return emailAnswer.email;
}
private async handleEmailOtpAuth(
sendId: string,
keyArray: Uint8Array,
apiUrl: string,
options: OptionValues,
): Promise<Response> {
const email = await this.promptForEmail();
const emailResponse = await this.getTokenWithRetry(sendId, {
kind: "email",
email: email,
});
if (emailResponse instanceof SendAccessToken) {
/*
At this point emailResponse should only be expected to be a GetSendAccessTokenError type,
but TS must have a logical branch in case it is a SendAccessToken type. If a valid token is
returned by the method above, something has gone wrong.
*/
return Response.error("Unexpected server response");
}
if (emailResponse.kind === "expected_server") {
const error = emailResponse.error;
if (emailAndOtpRequired(error)) {
const promptResponse = await this.promptForOtp(sendId, email);
// Use retry helper for expired token handling
const otpResponse = await this.getTokenWithRetry(sendId, {
kind: "email_otp",
email: email,
otp: promptResponse,
});
if (otpResponse instanceof SendAccessToken) {
return await this.accessSendWithToken(otpResponse, keyArray, apiUrl, options);
}
if (otpResponse.kind === "expected_server") {
const error = otpResponse.error;
if (otpInvalid(error)) {
return Response.badRequest("Invalid email or verification code");
}
/*
If the following evaluates to true, it means that the email address provided was not
configured to be used for email OTP for this Send.
To avoid leaking information that would allow email enumeration, instead return an
error indicating that some component of the email OTP challenge was invalid.
*/
if (emailAndOtpRequired(error)) {
return Response.badRequest("Invalid email or verification code");
}
}
return this.handleError(otpResponse);
}
}
return this.handleError(emailResponse);
}
private async handlePasswordAuth(
sendId: string,
keyArray: Uint8Array,
apiUrl: string,
options: OptionValues,
): Promise<Response> {
let password = options.password;
if (password == null || password === "") {
if (options.passwordfile) {
password = await NodeUtils.readFirstLine(options.passwordfile);
} else if (options.passwordenv && process.env[options.passwordenv]) {
password = process.env[options.passwordenv];
}
}
if ((password == null || password === "") && this.canInteract) {
const answer = await inquirer.createPromptModule({ output: process.stderr })({
type: "password",
name: "password",
message: "Send password:",
});
password = answer.password;
}
if (!password) {
return Response.badRequest("Password required");
}
const passwordHashB64 = await this.getUnlockedPassword(password, keyArray);
// Use retry helper for expired token handling
const response = await this.getTokenWithRetry(sendId, {
kind: "password",
passwordHashB64: passwordHashB64 as SendHashedPasswordB64,
});
if (response instanceof SendAccessToken) {
return await this.accessSendWithToken(response, keyArray, apiUrl, options);
}
if (response.kind === "expected_server") {
const error = response.error;
if (passwordHashB64Invalid(error)) {
return Response.badRequest("Invalid password");
}
} else if (response.kind === "unexpected_server") {
return Response.error("Server error: " + JSON.stringify(response.error));
} else if (response.kind === "unknown") {
return Response.error("Error: " + response.error);
}
return Response.error("Authentication failed");
}
private async accessSendWithToken(
accessToken: SendAccessToken,
keyArray: Uint8Array,
apiUrl: string,
options: OptionValues,
): Promise<Response> {
try {
const sendResponse = await this.sendApiService.postSendAccessV2(accessToken, apiUrl);
const sendAccess = new SendAccess(sendResponse);
this.decKey = await this.keyService.makeSendKey(keyArray);
const decryptedView = await sendAccess.decrypt(this.decKey);
if (options.obj != null) {
return Response.success(new SendAccessResponse(decryptedView));
}
switch (decryptedView.type) {
case SendType.Text:
process.stdout.write(decryptedView?.text?.text);
return Response.success();
case SendType.File: {
const downloadData = await this.sendApiService.getSendFileDownloadDataV2(
decryptedView,
accessToken,
apiUrl,
);
const decryptBufferFn = async (resp: globalThis.Response) => {
const encBuf = await EncArrayBuffer.fromResponse(resp);
return this.encryptService.decryptFileData(encBuf, this.decKey);
};
return await this.saveAttachmentToFile(
downloadData.url,
decryptedView?.file?.fileName,
decryptBufferFn,
options.output,
);
}
default:
return Response.success(new SendAccessResponse(decryptedView));
}
} catch (e) {
if (e instanceof ErrorResponse) {
if (e.statusCode === 404) {
return Response.notFound();
}
}
return Response.error(e);
}
}
private async sendRequest(
url: string,
id: string,

View File

@@ -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);