diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 07685b7240d..8fb7c9342b6 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -215,8 +215,10 @@ import { PasswordStrengthServiceAbstraction, } from "@bitwarden/common/tools/password-strength"; import { createSystemServiceProvider } from "@bitwarden/common/tools/providers"; +import { SendApiServiceSelector } from "@bitwarden/common/tools/send/services/send-api-service.selector"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service"; import { SendApiService as SendApiServiceAbstraction } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { SendSdkApiService } from "@bitwarden/common/tools/send/services/send-sdk-api.service"; import { SendStateProvider } from "@bitwarden/common/tools/send/services/send-state.provider"; import { SendService } from "@bitwarden/common/tools/send/services/send.service"; import { InternalSendService as InternalSendServiceAbstraction } from "@bitwarden/common/tools/send/services/send.service.abstraction"; @@ -1150,11 +1152,22 @@ export default class MainBackground { this.encryptService, this.configService, ); - this.sendApiService = new SendApiService( + const legacySendApiService = new SendApiService( this.apiService, this.fileUploadService, this.sendService, ); + this.sendApiService = new SendApiServiceSelector( + this.configService, + legacySendApiService, + new SendSdkApiService( + this.sdkService, + legacySendApiService, + this.sendService, + this.accountService, + this.logService, + ), + ); this.avatarService = new AvatarService(this.apiService, this.stateProvider); diff --git a/apps/cli/src/service-container/service-container.ts b/apps/cli/src/service-container/service-container.ts index e17c003731d..b31c6b30196 100644 --- a/apps/cli/src/service-container/service-container.ts +++ b/apps/cli/src/service-container/service-container.ts @@ -156,7 +156,9 @@ import { PasswordStrengthServiceAbstraction, } from "@bitwarden/common/tools/password-strength"; import { createSystemServiceProvider } from "@bitwarden/common/tools/providers"; +import { SendApiServiceSelector } from "@bitwarden/common/tools/send/services/send-api-service.selector"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service"; +import { SendSdkApiService } from "@bitwarden/common/tools/send/services/send-sdk-api.service"; import { SendStateProvider } from "@bitwarden/common/tools/send/services/send-state.provider"; import { SendService } from "@bitwarden/common/tools/send/services/send.service"; import { UserId } from "@bitwarden/common/types/guid"; @@ -319,7 +321,7 @@ export class ServiceContainer { folderApiService: FolderApiService; userVerificationApiService: UserVerificationApiService; organizationApiService: OrganizationApiServiceAbstraction; - sendApiService: SendApiService; + sendApiService: SendApiServiceSelector; sendTokenService: SendTokenService; sendPasswordService: SendPasswordService; devicesApiService: DevicesApiServiceAbstraction; @@ -652,11 +654,22 @@ export class ServiceContainer { this.configService, ); - this.sendApiService = this.sendApiService = new SendApiService( + const legacySendApiService = new SendApiService( this.apiService, this.fileUploadService, this.sendService, ); + this.sendApiService = new SendApiServiceSelector( + this.configService, + legacySendApiService, + new SendSdkApiService( + this.sdkService, + legacySendApiService, + this.sendService, + this.accountService, + this.logService, + ), + ); this.sendPasswordService = new DefaultSendPasswordService(this.cryptoFunctionService); diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 3c28e4de9e3..daa77cd8fa5 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -310,8 +310,10 @@ import { PasswordStrengthService, PasswordStrengthServiceAbstraction, } from "@bitwarden/common/tools/password-strength"; +import { SendApiServiceSelector } from "@bitwarden/common/tools/send/services/send-api-service.selector"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service"; import { SendApiService as SendApiServiceAbstraction } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { SendSdkApiService } from "@bitwarden/common/tools/send/services/send-sdk-api.service"; import { SendStateProvider as SendStateProvider } from "@bitwarden/common/tools/send/services/send-state.provider"; import { SendStateProvider as SendStateProviderAbstraction } from "@bitwarden/common/tools/send/services/send-state.provider.abstraction"; import { SendService } from "@bitwarden/common/tools/send/services/send.service"; @@ -936,10 +938,20 @@ const safeProviders: SafeProvider[] = [ deps: [StateProvider], }), safeProvider({ - provide: SendApiServiceAbstraction, + provide: SendApiService, useClass: SendApiService, deps: [ApiServiceAbstraction, FileUploadServiceAbstraction, InternalSendService], }), + safeProvider({ + provide: SendSdkApiService, + useClass: SendSdkApiService, + deps: [SdkService, SendApiService, InternalSendService, AccountServiceAbstraction, LogService], + }), + safeProvider({ + provide: SendApiServiceAbstraction, + useClass: SendApiServiceSelector, + deps: [ConfigService, SendApiService, SendSdkApiService], + }), safeProvider({ provide: KeyApiService, useClass: DefaultKeyApiService, diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index a32a2dd3758..c23bc9c9c8b 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -62,6 +62,7 @@ export enum FeatureFlag { /* Tools */ SendControls = "pm-31885-send-controls", + Pm30110SdkSendsApi = "pm-30110-sdk-sends-api", SendEventLogging = "pm-36560-send-event-logging", /* DIRT */ @@ -142,6 +143,7 @@ export const DefaultFeatureFlagValue = { /* Tools */ [FeatureFlag.SendControls]: FALSE, + [FeatureFlag.Pm30110SdkSendsApi]: FALSE, [FeatureFlag.SendEventLogging]: FALSE, /* DIRT */ diff --git a/libs/common/src/tools/send/services/send-api-service.selector.spec.ts b/libs/common/src/tools/send/services/send-api-service.selector.spec.ts new file mode 100644 index 00000000000..9586e29c662 --- /dev/null +++ b/libs/common/src/tools/send/services/send-api-service.selector.spec.ts @@ -0,0 +1,318 @@ +import { mock, MockProxy } from "jest-mock-extended"; +import { BehaviorSubject } from "rxjs"; + +import { SendAccessToken } from "../../../auth/send-access"; +import { FeatureFlag } from "../../../enums/feature-flag.enum"; +import { ConfigService } from "../../../platform/abstractions/config/config.service"; +import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer"; +import { Send } from "../models/domain/send"; +import { SendAccessRequest } from "../models/request/send-access.request"; +import { SendAccessView } from "../models/view/send-access.view"; +import { AuthType } from "../types/auth-type"; +import { SendType } from "../types/send-type"; + +import { SendApiServiceSelector } from "./send-api-service.selector"; +import { SendApiService } from "./send-api.service"; +import { SendSdkApiService } from "./send-sdk-api.service"; + +describe("SendApiServiceSelector", () => { + let configService: MockProxy; + let legacy: MockProxy; + let sdk: MockProxy; + let flag$: BehaviorSubject; + + function buildSelector(initialFlag: boolean): SendApiServiceSelector { + flag$ = new BehaviorSubject(initialFlag); + configService.getFeatureFlag$.mockImplementation((key) => + key === FeatureFlag.Pm30110SdkSendsApi ? flag$.asObservable() : (undefined as any), + ); + return new SendApiServiceSelector(configService, legacy, sdk); + } + + beforeEach(() => { + configService = mock(); + legacy = mock(); + sdk = mock(); + }); + + describe("save", () => { + it("routes to legacy when creating a file send, even with the flag on", async () => { + const selector = buildSelector(true); + const send = new Send(); + send.id = null; + send.type = SendType.File; + const buffer = mock(); + + await selector.save([send, buffer]); + + expect(legacy.save).toHaveBeenCalledWith([send, buffer]); + expect(sdk.save).not.toHaveBeenCalled(); + }); + + it("routes to SDK for text creates when the flag is on", async () => { + const selector = buildSelector(true); + const send = new Send(); + send.id = null; + send.type = SendType.Text; + send.authType = AuthType.None; + const buffer = mock(); + + await selector.save([send, buffer]); + + expect(sdk.save).toHaveBeenCalledWith([send, buffer]); + expect(legacy.save).not.toHaveBeenCalled(); + }); + + it("routes to SDK for file edits when the flag is on", async () => { + const selector = buildSelector(true); + const send = new Send(); + send.id = "existing-id"; + send.type = SendType.File; + send.authType = AuthType.None; + const buffer = mock(); + + await selector.save([send, buffer]); + + expect(sdk.save).toHaveBeenCalledWith([send, buffer]); + expect(legacy.save).not.toHaveBeenCalled(); + }); + + it("routes to legacy for password-protected creates, even with the flag on", async () => { + const selector = buildSelector(true); + const send = new Send(); + send.id = null; + send.type = SendType.Text; + send.authType = AuthType.Password; + const buffer = mock(); + + await selector.save([send, buffer]); + + expect(legacy.save).toHaveBeenCalledWith([send, buffer]); + expect(sdk.save).not.toHaveBeenCalled(); + }); + + it("routes to legacy for password-protected edits, even with the flag on", async () => { + const selector = buildSelector(true); + const send = new Send(); + send.id = "existing-id"; + send.type = SendType.Text; + send.authType = AuthType.Password; + const buffer = mock(); + + await selector.save([send, buffer]); + + expect(legacy.save).toHaveBeenCalledWith([send, buffer]); + expect(sdk.save).not.toHaveBeenCalled(); + }); + + it("routes to legacy when the flag is off", async () => { + const selector = buildSelector(false); + const send = new Send(); + send.id = "existing-id"; + send.type = SendType.Text; + const buffer = mock(); + + await selector.save([send, buffer]); + + expect(legacy.save).toHaveBeenCalledWith([send, buffer]); + expect(sdk.save).not.toHaveBeenCalled(); + }); + }); + + describe.each([ + ["delete", (s: SendApiServiceSelector) => s.delete("id")], + ["removePassword", (s: SendApiServiceSelector) => s.removePassword("id")], + ["deleteSend", (s: SendApiServiceSelector) => s.deleteSend("id")], + ])("%s — flag-controlled, no overrides", (methodName, invoke) => { + it("routes to SDK when the flag is on", async () => { + const selector = buildSelector(true); + + await invoke(selector); + + expect((sdk as any)[methodName]).toHaveBeenCalledWith("id"); + expect((legacy as any)[methodName]).not.toHaveBeenCalled(); + }); + + it("routes to legacy when the flag is off", async () => { + const selector = buildSelector(false); + + await invoke(selector); + + expect((legacy as any)[methodName]).toHaveBeenCalledWith("id"); + expect((sdk as any)[methodName]).not.toHaveBeenCalled(); + }); + }); + + describe.each([ + ["getSend", (s: SendApiServiceSelector) => s.getSend("id"), ["id"]], + ["getSends", (s: SendApiServiceSelector) => s.getSends(), []], + ["putSendRemovePassword", (s: SendApiServiceSelector) => s.putSendRemovePassword("id"), ["id"]], + ])("%s — always legacy", (methodName, invoke, expectedArgs) => { + it.each([true, false])("routes to legacy regardless of flag (flag=%s)", async (flagOn) => { + const selector = buildSelector(flagOn); + + await invoke(selector); + + expect((legacy as any)[methodName]).toHaveBeenCalledWith(...expectedArgs); + expect((sdk as any)[methodName]).not.toHaveBeenCalled(); + }); + }); + + describe("postSendAccess", () => { + const request = new SendAccessRequest(); + + it("routes to legacy when apiUrl is supplied, even with the flag on", async () => { + const selector = buildSelector(true); + + await selector.postSendAccess("id", request, "https://other.example"); + + expect(legacy.postSendAccess).toHaveBeenCalledWith("id", request, "https://other.example"); + expect(sdk.postSendAccess).not.toHaveBeenCalled(); + }); + + it("routes to SDK without apiUrl when the flag is on", async () => { + const selector = buildSelector(true); + + await selector.postSendAccess("id", request); + + expect(sdk.postSendAccess).toHaveBeenCalledWith("id", request); + expect(legacy.postSendAccess).not.toHaveBeenCalled(); + }); + + it("routes to legacy without apiUrl when the flag is off", async () => { + const selector = buildSelector(false); + + await selector.postSendAccess("id", request); + + expect(legacy.postSendAccess).toHaveBeenCalledWith("id", request); + expect(sdk.postSendAccess).not.toHaveBeenCalled(); + }); + }); + + describe("postSendAccessV2", () => { + const accessToken: SendAccessToken = { token: "tok" } as SendAccessToken; + + it("routes to legacy when apiUrl is supplied, even with the flag on", async () => { + const selector = buildSelector(true); + + await selector.postSendAccessV2(accessToken, "https://other.example"); + + expect(legacy.postSendAccessV2).toHaveBeenCalledWith(accessToken, "https://other.example"); + expect(sdk.postSendAccessV2).not.toHaveBeenCalled(); + }); + + it("routes to SDK without apiUrl when the flag is on", async () => { + const selector = buildSelector(true); + + await selector.postSendAccessV2(accessToken); + + expect(sdk.postSendAccessV2).toHaveBeenCalledWith(accessToken); + expect(legacy.postSendAccessV2).not.toHaveBeenCalled(); + }); + + it("routes to legacy without apiUrl when the flag is off", async () => { + const selector = buildSelector(false); + + await selector.postSendAccessV2(accessToken); + + expect(legacy.postSendAccessV2).toHaveBeenCalledWith(accessToken); + expect(sdk.postSendAccessV2).not.toHaveBeenCalled(); + }); + }); + + describe("getSendFileDownloadData", () => { + const accessView = mock(); + const request = new SendAccessRequest(); + + it("routes to legacy when apiUrl is supplied, even with the flag on", async () => { + const selector = buildSelector(true); + + await selector.getSendFileDownloadData(accessView, request, "https://other.example"); + + expect(legacy.getSendFileDownloadData).toHaveBeenCalledWith( + accessView, + request, + "https://other.example", + ); + expect(sdk.getSendFileDownloadData).not.toHaveBeenCalled(); + }); + + it("routes to SDK without apiUrl when the flag is on", async () => { + const selector = buildSelector(true); + + await selector.getSendFileDownloadData(accessView, request); + + expect(sdk.getSendFileDownloadData).toHaveBeenCalledWith(accessView, request); + expect(legacy.getSendFileDownloadData).not.toHaveBeenCalled(); + }); + + it("routes to legacy without apiUrl when the flag is off", async () => { + const selector = buildSelector(false); + + await selector.getSendFileDownloadData(accessView, request); + + expect(legacy.getSendFileDownloadData).toHaveBeenCalledWith(accessView, request); + expect(sdk.getSendFileDownloadData).not.toHaveBeenCalled(); + }); + }); + + describe("getSendFileDownloadDataV2", () => { + const accessView = mock(); + const accessToken: SendAccessToken = { token: "tok" } as SendAccessToken; + + it("routes to legacy when apiUrl is supplied, even with the flag on", async () => { + const selector = buildSelector(true); + + await selector.getSendFileDownloadDataV2(accessView, accessToken, "https://other.example"); + + expect(legacy.getSendFileDownloadDataV2).toHaveBeenCalledWith( + accessView, + accessToken, + "https://other.example", + ); + expect(sdk.getSendFileDownloadDataV2).not.toHaveBeenCalled(); + }); + + it("routes to SDK without apiUrl when the flag is on", async () => { + const selector = buildSelector(true); + + await selector.getSendFileDownloadDataV2(accessView, accessToken); + + expect(sdk.getSendFileDownloadDataV2).toHaveBeenCalledWith(accessView, accessToken); + expect(legacy.getSendFileDownloadDataV2).not.toHaveBeenCalled(); + }); + + it("routes to legacy without apiUrl when the flag is off", async () => { + const selector = buildSelector(false); + + await selector.getSendFileDownloadDataV2(accessView, accessToken); + + expect(legacy.getSendFileDownloadDataV2).toHaveBeenCalledWith(accessView, accessToken); + expect(sdk.getSendFileDownloadDataV2).not.toHaveBeenCalled(); + }); + }); + + describe("feature flag caching", () => { + it("subscribes to the feature flag once across many calls", async () => { + const selector = buildSelector(true); + + await selector.delete("a"); + await selector.delete("b"); + await selector.deleteSend("c"); + await selector.removePassword("d"); + + expect(configService.getFeatureFlag$).toHaveBeenCalledTimes(1); + }); + + it("picks up flag changes for subsequent calls", async () => { + const selector = buildSelector(true); + + await selector.delete("first"); + flag$.next(false); + await selector.delete("second"); + + expect(sdk.delete).toHaveBeenCalledWith("first"); + expect(legacy.delete).toHaveBeenCalledWith("second"); + }); + }); +}); diff --git a/libs/common/src/tools/send/services/send-api-service.selector.ts b/libs/common/src/tools/send/services/send-api-service.selector.ts new file mode 100644 index 00000000000..50de9bfc5c6 --- /dev/null +++ b/libs/common/src/tools/send/services/send-api-service.selector.ts @@ -0,0 +1,168 @@ +import { Observable, firstValueFrom, map, shareReplay } from "rxjs"; + +import { SendAccessToken } from "../../../auth/send-access"; +import { FeatureFlag } from "../../../enums/feature-flag.enum"; +import { ListResponse } from "../../../models/response/list.response"; +import { ConfigService } from "../../../platform/abstractions/config/config.service"; +import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer"; +import { Send } from "../models/domain/send"; +import { SendAccessRequest } from "../models/request/send-access.request"; +import { SendAccessResponse } from "../models/response/send-access.response"; +import { SendFileDownloadDataResponse } from "../models/response/send-file-download-data.response"; +import { SendResponse } from "../models/response/send.response"; +import { SendAccessView } from "../models/view/send-access.view"; +import { AuthType } from "../types/auth-type"; +import { SendType } from "../types/send-type"; + +import { SendApiService } from "./send-api.service"; +import { SendApiService as SendApiServiceAbstraction } from "./send-api.service.abstraction"; +import { SendSdkApiService } from "./send-sdk-api.service"; + +/** + * Selects between {@link SendApiService} and {@link SendSdkApiService} based on the + * `pm-30110-sdk-sends-api` feature flag. + * + * Methods whose return type is a wire-encrypted shape the SDK cannot produce (`getSend`, + * `getSends`, `putSendRemovePassword`) always route to legacy. Mutations and access-side + * methods are flag-controlled; the SDK service refetches the encrypted form via legacy + * after mutations to keep `InternalSendService` coherent. + * + * A "cross-instance Send" is a Send hosted on a different Bitwarden server than the + * client is signed in to — typically the CLI receiving a self-hosted or EU-cloud Send + * link. Callers signal this by passing `apiUrl`; the selector routes those calls to + * legacy because the SDK client targets only its configured environment. + */ +export class SendApiServiceSelector implements SendApiServiceAbstraction { + private readonly service$: Observable; + + constructor( + configService: ConfigService, + private sendApiService: SendApiService, + private sendSdkApiService: SendSdkApiService, + ) { + this.service$ = configService.getFeatureFlag$(FeatureFlag.Pm30110SdkSendsApi).pipe( + map((useSdk) => (useSdk ? this.sendSdkApiService : this.sendApiService)), + shareReplay({ bufferSize: 1, refCount: false }), + ); + } + + private getService(): Promise { + return firstValueFrom(this.service$); + } + + /** + * Routes saves to SDK when the flag is on, except for two cases that fall back to + * legacy regardless: new file sends (the SDK generates its own send key, which + * wouldn't match the caller's pre-encrypted file buffer) and password-protected + * sends (the SDK re-applies PBKDF2 to `auth.password`, double-hashing the keyB64 + * the client already derived). + */ + async save(sendData: [Send, EncArrayBuffer]): Promise { + const [send] = sendData; + if (send.id == null && send.type === SendType.File) { + return this.sendApiService.save(sendData); + } + if (send.authType === AuthType.Password) { + return this.sendApiService.save(sendData); + } + return (await this.getService()).save(sendData); + } + + async delete(id: string): Promise { + return (await this.getService()).delete(id); + } + + /** + * Removes the auth from a send and updates local state. + * + * Under the SDK flag this calls the V2 endpoint, which removes **all auth** on the + * send (password and any other auth type), not just the password. The legacy path + * uses V1, which removes only the password. Callers that need the V1 semantics + * specifically should keep the flag off until V2 ships everywhere. + */ + async removePassword(id: string): Promise { + return (await this.getService()).removePassword(id); + } + + /** + * Always routed to legacy. Returns a wire-encrypted `SendResponse`, which the SDK + * cannot produce (the SDK only exposes plaintext views). + */ + async getSend(id: string): Promise { + return this.sendApiService.getSend(id); + } + + /** + * Accesses a send. Routes to legacy whenever `apiUrl` is supplied (cross-instance + * receive, e.g. the CLI opening a self-hosted Send link while signed in to a + * different server) because the SDK client targets only its configured environment. + */ + async postSendAccess( + id: string, + request: SendAccessRequest, + apiUrl?: string, + ): Promise { + if (apiUrl != null) { + return this.sendApiService.postSendAccess(id, request, apiUrl); + } + return (await this.getService()).postSendAccess(id, request); + } + + async postSendAccessV2( + accessToken: SendAccessToken, + apiUrl?: string, + ): Promise { + if (apiUrl != null) { + return this.sendApiService.postSendAccessV2(accessToken, apiUrl); + } + return (await this.getService()).postSendAccessV2(accessToken); + } + + /** + * Always routed to legacy. Returns a wire-encrypted list of `SendResponse`, which the + * SDK cannot produce; see {@link getSend}. + */ + async getSends(): Promise> { + return this.sendApiService.getSends(); + } + + /** + * Always routed to legacy. The selector's `removePassword` is the higher-level flow that + * also refreshes local state; this lower-level method returns a wire-encrypted + * `SendResponse` the SDK cannot produce. + * + * Note that the legacy `PUT /sends/{id}/remove-password` endpoint is V1 — it removes + * only the password, not all auth types. See {@link removePassword} for the V2 + * (all-auth) behavior under the SDK flag. + */ + async putSendRemovePassword(id: string): Promise { + return this.sendApiService.putSendRemovePassword(id); + } + + async deleteSend(id: string): Promise { + return (await this.getService()).deleteSend(id); + } + + /** See {@link postSendAccess} — cross-instance callers (those passing `apiUrl`) route to legacy. */ + async getSendFileDownloadData( + send: SendAccessView, + request: SendAccessRequest, + apiUrl?: string, + ): Promise { + if (apiUrl != null) { + return this.sendApiService.getSendFileDownloadData(send, request, apiUrl); + } + return (await this.getService()).getSendFileDownloadData(send, request); + } + + async getSendFileDownloadDataV2( + send: SendAccessView, + accessToken: SendAccessToken, + apiUrl?: string, + ): Promise { + if (apiUrl != null) { + return this.sendApiService.getSendFileDownloadDataV2(send, accessToken, apiUrl); + } + return (await this.getService()).getSendFileDownloadDataV2(send, accessToken); + } +} diff --git a/libs/common/src/tools/send/services/send-api.service.abstraction.ts b/libs/common/src/tools/send/services/send-api.service.abstraction.ts index a7e36d8c8b1..302d4a91330 100644 --- a/libs/common/src/tools/send/services/send-api.service.abstraction.ts +++ b/libs/common/src/tools/send/services/send-api.service.abstraction.ts @@ -4,10 +4,8 @@ import { ListResponse } from "../../../models/response/list.response"; import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer"; import { Send } from "../models/domain/send"; import { SendAccessRequest } from "../models/request/send-access.request"; -import { SendRequest } from "../models/request/send.request"; import { SendAccessResponse } from "../models/response/send-access.response"; import { SendFileDownloadDataResponse } from "../models/response/send-file-download-data.response"; -import { SendFileUploadDataResponse } from "../models/response/send-file-upload-data.response"; import { SendResponse } from "../models/response/send.response"; import { SendAccessView } from "../models/view/send-access.view"; @@ -23,10 +21,6 @@ export abstract class SendApiService { apiUrl?: string, ): Promise; abstract getSends(): Promise>; - abstract postSend(request: SendRequest): Promise; - abstract postFileTypeSend(request: SendRequest): Promise; - abstract postSendFile(sendId: string, fileId: string, data: FormData): Promise; - abstract putSend(id: string, request: SendRequest): Promise; abstract putSendRemovePassword(id: string): Promise; abstract deleteSend(id: string): Promise; abstract getSendFileDownloadData( @@ -39,10 +33,6 @@ export abstract class SendApiService { accessToken: SendAccessToken, apiUrl?: string, ): Promise; - abstract renewSendFileUploadUrl( - sendId: string, - fileId: string, - ): Promise; abstract removePassword(id: string): Promise; abstract delete(id: string): Promise; abstract save(sendData: [Send, EncArrayBuffer]): Promise; 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 fdbbd579c3f..783777e4f65 100644 --- a/libs/common/src/tools/send/services/send-api.service.ts +++ b/libs/common/src/tools/send/services/send-api.service.ts @@ -118,39 +118,6 @@ export class SendApiService implements SendApiServiceAbstraction { return new ListResponse(r, SendResponse); } - async postSend(request: SendRequest): Promise { - const r = await this.apiService.send("POST", "/sends", request, true, true); - return new SendResponse(r); - } - - async postFileTypeSend(request: SendRequest): Promise { - const r = await this.apiService.send("POST", "/sends/file/v2", request, true, true); - return new SendFileUploadDataResponse(r); - } - - async renewSendFileUploadUrl( - sendId: string, - fileId: string, - ): Promise { - const r = await this.apiService.send( - "GET", - "/sends/" + sendId + "/file/" + fileId, - null, - true, - true, - ); - return new SendFileUploadDataResponse(r); - } - - postSendFile(sendId: string, fileId: string, data: FormData): Promise { - return this.apiService.send("POST", "/sends/" + sendId + "/file/" + fileId, data, true, false); - } - - async putSend(id: string, request: SendRequest): Promise { - const r = await this.apiService.send("PUT", "/sends/" + id, request, true, true); - return new SendResponse(r); - } - async putSendRemovePassword(id: string): Promise { const r = await this.apiService.send( "PUT", @@ -187,6 +154,39 @@ export class SendApiService implements SendApiServiceAbstraction { // Send File Upload methods + private async postSend(request: SendRequest): Promise { + const r = await this.apiService.send("POST", "/sends", request, true, true); + return new SendResponse(r); + } + + private async postFileTypeSend(request: SendRequest): Promise { + const r = await this.apiService.send("POST", "/sends/file/v2", request, true, true); + return new SendFileUploadDataResponse(r); + } + + private async renewSendFileUploadUrl( + sendId: string, + fileId: string, + ): Promise { + const r = await this.apiService.send( + "GET", + "/sends/" + sendId + "/file/" + fileId, + null, + true, + true, + ); + return new SendFileUploadDataResponse(r); + } + + private postSendFile(sendId: string, fileId: string, data: FormData): Promise { + return this.apiService.send("POST", "/sends/" + sendId + "/file/" + fileId, data, true, false); + } + + private async putSend(id: string, request: SendRequest): Promise { + const r = await this.apiService.send("PUT", "/sends/" + id, request, true, true); + return new SendResponse(r); + } + private async upload(sendData: [Send, EncArrayBuffer]): Promise { const request = new SendRequest(sendData[0], sendData[1]?.buffer.byteLength); diff --git a/libs/common/src/tools/send/services/send-sdk-api.service.ts b/libs/common/src/tools/send/services/send-sdk-api.service.ts new file mode 100644 index 00000000000..42d56ca8bd8 --- /dev/null +++ b/libs/common/src/tools/send/services/send-sdk-api.service.ts @@ -0,0 +1,344 @@ +import { catchError, firstValueFrom, switchMap } from "rxjs"; + +import { + PasswordManagerClient, + SendAddRequest, + SendAuthType, + SendEditRequest, + SendId as SdkSendId, + SendView as SdkSendView, + SendViewType, +} from "@bitwarden/sdk-internal"; + +import { AccountService } from "../../../auth/abstractions/account.service"; +import { SendAccessToken } from "../../../auth/send-access"; +import { getUserId } from "../../../auth/services/account.service"; +import { ListResponse } from "../../../models/response/list.response"; +import { LogService } from "../../../platform/abstractions/log.service"; +import { SdkService, asUuid } from "../../../platform/abstractions/sdk/sdk.service"; +import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer"; +import { UserId } from "../../../types/guid"; +import { SendData } from "../models/data/send.data"; +import { Send } from "../models/domain/send"; +import { SendAccessRequest } from "../models/request/send-access.request"; +import { SendAccessResponse } from "../models/response/send-access.response"; +import { SendFileDownloadDataResponse } from "../models/response/send-file-download-data.response"; +import { SendResponse } from "../models/response/send.response"; +import { SendAccessView } from "../models/view/send-access.view"; +import { SendView } from "../models/view/send.view"; +import { AuthType } from "../types/auth-type"; +import { SendType } from "../types/send-type"; + +import { SendApiService } from "./send-api.service"; +import { SendApiService as SendApiServiceAbstraction } from "./send-api.service.abstraction"; +import { InternalSendService } from "./send.service.abstraction"; + +/** + * SDK-backed implementation of `SendApiService`. Save/removePassword mutate via the SDK + * then refetch via legacy to keep `InternalSendService` populated with `EncString`-shaped + * data. Methods returning wire-encrypted shapes have no SDK equivalent and are routed to + * legacy by `SendApiServiceSelector`; the throw-stubs here guard direct callers. + */ +export class SendSdkApiService implements SendApiServiceAbstraction { + constructor( + private sdkService: SdkService, + private legacySendApiService: SendApiService, + private sendService: InternalSendService, + private accountService: AccountService, + private logService: LogService, + ) {} + + /** + * Saves a send via the SDK. After the mutation, refetches the wire-encrypted form via + * the legacy service to keep `InternalSendService` populated with `EncString`-shaped + * data. Patches the input with server-assigned id/accessId on create so callers reading + * those after `save()` continue to work. + * + * Two cases the SDK cannot currently handle are routed to legacy by + * `SendApiServiceSelector` and rejected here as a guard for direct callers: + * + * - **New file sends.** `SendService.encrypt` produces a pre-encrypted buffer under a + * client-derived key; the SDK's `create_file_send` generates its own key. + * - **Password-protected sends.** `Send.password` is already the PBKDF2-derived + * `keyB64`, but the SDK's `SendAuthType::auth_data` + * (`bitwarden-send/src/send.rs:160-163`) re-applies PBKDF2 to whatever string sits in + * `auth.password`, double-hashing the value. No skip-hash variant exists on the + * public request types. + */ + async save(sendData: [Send, EncArrayBuffer]): Promise { + const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + const [send] = sendData; + if (send.id == null && send.type === SendType.File) { + throw new Error("SendSdkApiService.save: file send creation requires SendApiService."); + } + if (send.authType === AuthType.Password) { + throw new Error("SendSdkApiService.save: password-protected sends require SendApiService."); + } + const sendView = await send.decrypt(userId); + const sdkView = await this.mutateSend(sendView, userId); + + // Patch server-assigned identifiers onto the input for callers that read them after + // save (matches the legacy SendApiService contract). The server always returns id + // and accessId on a successful create. + const sendId = sdkView.id as unknown as string; + if (send.id == null) { + send.id = sendId; + send.accessId = sdkView.accessId as unknown as string; + } + + try { + return await this.refreshSendFromServer(sendId); + } catch (error) { + // The SDK mutation already landed on the server; only the encrypted-form refetch + // failed. Surfacing this as a save error would prompt the user to retry, which on + // a new send would duplicate it server-side. Log and return the caller's input + // Send — its EncString fields are still valid (the caller encrypted them moments + // ago), so decryption in post-save paths works. InternalSendService reconciles on + // the next sync. + this.logService.error(`Send refresh failed after successful mutation: ${error}`); + return send; + } + } + + async delete(id: string): Promise { + const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + await firstValueFrom( + this.sdkService.userClient$(userId).pipe( + switchMap(async (sdk) => { + if (!sdk) { + throw new Error("SDK not available"); + } + using ref = sdk.take(); + return await ref.value.sends().delete(asUuid(id)); + }), + catchError((error: unknown) => { + this.logService.error(`Failed to delete send: ${error}`); + throw error; + }), + ), + ); + await this.sendService.delete(id); + } + + // Note: the SDK calls the V2 endpoint which removes all auth (password and any other + // auth type), not just the password. + async removePassword(id: string): Promise { + const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + await firstValueFrom( + this.sdkService.userClient$(userId).pipe( + switchMap(async (sdk) => { + if (!sdk) { + throw new Error("SDK not available"); + } + using ref = sdk.take(); + return await ref.value.sends().remove_password(asUuid(id)); + }), + catchError((error: unknown) => { + this.logService.error(`Failed to remove send auth: ${error}`); + throw error; + }), + ), + ); + await this.refreshSendFromServer(id); + } + + /** + * Not supported via SDK — returns wire-encrypted `SendResponse`, which the SDK cannot + * produce. `SendApiServiceSelector` routes calls to `SendApiService`; this stub catches + * direct callers that bypass the selector. + */ + getSend(_id: string): Promise { + return Promise.reject(new Error("SendSdkApiService.getSend: use SendApiService.")); + } + + // `apiUrl` is intentionally omitted; `SendApiServiceSelector` routes per-call `apiUrl` + // to the legacy service. + async postSendAccess(id: string, request: SendAccessRequest): Promise { + const sdk: PasswordManagerClient = await firstValueFrom(this.sdkService.client$); + const view = await sdk.sends().access_send_v1(id, request.password ?? undefined); + return new SendAccessResponse(view); + } + + async postSendAccessV2(accessToken: SendAccessToken): Promise { + const sdk: PasswordManagerClient = await firstValueFrom(this.sdkService.client$); + const view = await sdk.sends().access_send(accessToken.token); + return new SendAccessResponse(view); + } + + /** + * Not supported via SDK — returns wire-encrypted `ListResponse`, which + * the SDK cannot produce. `SendApiServiceSelector` routes calls to `SendApiService`; + * this stub catches direct callers that bypass the selector. + */ + getSends(): Promise> { + return Promise.reject(new Error("SendSdkApiService.getSends: use SendApiService.")); + } + + /** + * Not supported via SDK — returns wire-encrypted `SendResponse`, which the SDK cannot + * produce. `SendApiServiceSelector` routes calls to `SendApiService`; this stub catches + * direct callers that bypass the selector. + */ + putSendRemovePassword(_id: string): Promise { + return Promise.reject( + new Error("SendSdkApiService.putSendRemovePassword: use SendApiService."), + ); + } + + async deleteSend(id: string): Promise { + const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + return firstValueFrom( + this.sdkService.userClient$(userId).pipe( + switchMap(async (sdk) => { + if (!sdk) { + throw new Error("SDK not available"); + } + using ref = sdk.take(); + return await ref.value.sends().delete(asUuid(id)); + }), + catchError((error: unknown) => { + this.logService.error(`Failed to delete send: ${error}`); + throw error; + }), + ), + ); + } + + // `apiUrl` is intentionally omitted; `SendApiServiceSelector` routes per-call `apiUrl` + // to the legacy service. + async getSendFileDownloadData( + send: SendAccessView, + request: SendAccessRequest, + ): Promise { + const sdk: PasswordManagerClient = await firstValueFrom(this.sdkService.client$); + const data = await sdk + .sends() + .get_file_download_data_v1(send.id, send.file.id, request.password ?? undefined); + return new SendFileDownloadDataResponse(data); + } + + // `apiUrl` is intentionally omitted; `SendApiServiceSelector` routes per-call `apiUrl` + // to the legacy service. + async getSendFileDownloadDataV2( + send: SendAccessView, + accessToken: SendAccessToken, + ): Promise { + const sdk: PasswordManagerClient = await firstValueFrom(this.sdkService.client$); + const data = await sdk.sends().get_file_download_data(accessToken.token, send.file.id); + return new SendFileDownloadDataResponse(data); + } + + private async mutateSend(sendView: SendView, userId: UserId): Promise { + return await firstValueFrom( + this.sdkService.userClient$(userId).pipe( + switchMap(async (sdk) => { + if (!sdk) { + throw new Error("SDK not available"); + } + using ref = sdk.take(); + const sendsClient = ref.value.sends(); + if (sendView.id == null) { + return await sendsClient.create(this.buildSendAddRequest(sendView)); + } + return await sendsClient.edit( + asUuid(sendView.id), + this.buildSendEditRequest(sendView), + ); + }), + catchError((error: unknown) => { + this.logService.error(`Failed to upload send: ${error}`); + throw error; + }), + ), + ); + } + + // After the SDK executes a mutation server-side, refetch the wire-encrypted form via + // the legacy API so InternalSendService stores EncString-shaped data and consumers + // that decrypt the returned Send work correctly. + private async refreshSendFromServer(id: string): Promise { + const response = await this.legacySendApiService.getSend(id); + const data = new SendData(response); + await this.sendService.upsert(data); + return new Send(data); + } + + private buildSendAddRequest(sendView: SendView): SendAddRequest { + return { + name: sendView.name, + notes: sendView.notes ?? undefined, + viewType: this.buildSendViewType(sendView), + maxAccessCount: sendView.maxAccessCount ?? undefined, + disabled: sendView.disabled, + hideEmail: sendView.hideEmail, + deletionDate: this.requireDeletionDate(sendView).toISOString(), + expirationDate: sendView.expirationDate?.toISOString() ?? undefined, + auth: this.buildSendAuth(sendView), + }; + } + + private buildSendEditRequest(sendView: SendView): SendEditRequest { + return { + name: sendView.name, + notes: sendView.notes ?? undefined, + viewType: this.buildSendViewType(sendView), + maxAccessCount: sendView.maxAccessCount ?? undefined, + disabled: sendView.disabled, + hideEmail: sendView.hideEmail, + deletionDate: this.requireDeletionDate(sendView).toISOString(), + expirationDate: sendView.expirationDate?.toISOString() ?? undefined, + auth: this.buildSendAuth(sendView), + }; + } + + // SendView.deletionDate is typed as `Date` but declared with a `null` default, and + // the file is `@ts-strict-ignore`. The SDK's SendAddRequest/SendEditRequest require a + // non-null `DateTime`. Fail fast at this boundary so a missing date surfaces as + // a clear error rather than a confusing `.toISOString()` TypeError or a server-side + // validation failure. + private requireDeletionDate(sendView: SendView): Date { + if (sendView.deletionDate == null) { + throw new Error("Send is missing a deletion date."); + } + return sendView.deletionDate; + } + + private buildSendViewType(sendView: SendView): SendViewType { + if (sendView.type === SendType.File) { + if (sendView.file == null || !sendView.file.fileName) { + throw new Error("File send is missing a file name."); + } + return { + File: { + id: sendView.file.id ?? undefined, + fileName: sendView.file.fileName, + size: sendView.file.size?.toString() ?? undefined, + sizeName: sendView.file.sizeName ?? undefined, + }, + }; + } + return { + Text: { + text: sendView.text?.text ?? undefined, + hidden: sendView.text?.hidden ?? false, + }, + }; + } + + // `AuthType.Password` is unreachable here: SendApiServiceSelector and the guard in + // `save()` both route password-protected sends to the legacy service because the SDK + // re-applies PBKDF2 to `auth.password`. When the SDK gains a skip-hash/preserve + // variant, the routing changes and a `case AuthType.Password` branch comes back. + private buildSendAuth(sendView: SendView): SendAuthType { + switch (sendView.authType) { + case AuthType.Email: + if (sendView.emails == null || sendView.emails.length === 0) { + throw new Error("Email-protected send is missing recipient emails."); + } + return { type: "emails", emails: sendView.emails }; + case AuthType.None: + default: + return { type: "none" }; + } + } +} diff --git a/package-lock.json b/package-lock.json index 84c4da94279..184a0871b26 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,9 +23,9 @@ "@angular/platform-browser": "21.2.11", "@angular/platform-browser-dynamic": "21.2.11", "@angular/router": "21.2.11", - "@bitwarden/commercial-sdk-internal": "0.2.0-main.798", + "@bitwarden/commercial-sdk-internal": "0.2.0-main.803", "@bitwarden/desktop-napi": "file:apps/desktop/desktop_native/napi", - "@bitwarden/sdk-internal": "0.2.0-main.798", + "@bitwarden/sdk-internal": "0.2.0-main.803", "@electron/fuses": "2.1.1", "@emotion/css": "11.13.5", "@koa/multer": "4.0.0", @@ -4067,9 +4067,9 @@ "link": true }, "node_modules/@bitwarden/commercial-sdk-internal": { - "version": "0.2.0-main.798", - "resolved": "https://registry.npmjs.org/@bitwarden/commercial-sdk-internal/-/commercial-sdk-internal-0.2.0-main.798.tgz", - "integrity": "sha512-Wdo5Y6z0LemYd32qsxnhqc448fVg0OQXbV/lfgL68GpU3cv+0G9fDijHy8KXlhegCtodCIg22uS7t0MRp6libw==", + "version": "0.2.0-main.803", + "resolved": "https://registry.npmjs.org/@bitwarden/commercial-sdk-internal/-/commercial-sdk-internal-0.2.0-main.803.tgz", + "integrity": "sha512-DE+CvtCUXlR25gVwSByiTFuYkfSC2OIS4KL3Uvelu3A5l3o7Lf2xa3ZIrw1VWVcxns5HA2o79jKdzqyuO9dIyw==", "license": "BITWARDEN SOFTWARE DEVELOPMENT KIT LICENSE AGREEMENT", "dependencies": { "type-fest": "^5.0.0" @@ -4187,9 +4187,9 @@ "link": true }, "node_modules/@bitwarden/sdk-internal": { - "version": "0.2.0-main.798", - "resolved": "https://registry.npmjs.org/@bitwarden/sdk-internal/-/sdk-internal-0.2.0-main.798.tgz", - "integrity": "sha512-1lNWgkjX/gw4KBm+XFf0a0QTOaXm2QPO5Ti4KfQlF2oYEnehWgoPjYAMEzbCM3650LEWUPfriVC3Z/Tofj2vZw==", + "version": "0.2.0-main.803", + "resolved": "https://registry.npmjs.org/@bitwarden/sdk-internal/-/sdk-internal-0.2.0-main.803.tgz", + "integrity": "sha512-hftVgjFgdKkPKhnKFMgQ+6OLRVOX60qAiHdgk8qQccUfHFT44Dhr/B0tbWr+V6LxOtJ+q1Htm4iWhJO57Th4jw==", "license": "GPL-3.0", "dependencies": { "type-fest": "^5.0.0" @@ -5517,28 +5517,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@electron/windows-sign": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.2.tgz", - "integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==", - "dev": true, - "license": "BSD-2-Clause", - "optional": true, - "peer": true, - "dependencies": { - "cross-dirname": "^0.1.0", - "debug": "^4.3.4", - "fs-extra": "^11.1.1", - "minimist": "^1.2.8", - "postject": "^1.0.0-alpha.6" - }, - "bin": { - "electron-windows-sign": "bin/electron-windows-sign.js" - }, - "engines": { - "node": ">=14.14" - } - }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -20192,15 +20170,6 @@ "integrity": "sha512-vt/iQokU0mtrT7ceRU75FSmWnIh5JFpLsUUUWYRmztYekOGm0ZbCuzwFTbNkq41k92y+0B8ChscFhRN9DhVZEA==", "license": "MIT" }, - "node_modules/cross-dirname": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", - "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/cross-env": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", @@ -35324,36 +35293,6 @@ "dev": true, "license": "MIT" }, - "node_modules/postject": { - "version": "1.0.0-alpha.6", - "resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz", - "integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "commander": "^9.4.0" - }, - "bin": { - "postject": "dist/cli.js" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/postject/node_modules/commander": { - "version": "9.5.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", - "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": "^12.20.0 || >=14" - } - }, "node_modules/powershell-utils": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", @@ -39365,6 +39304,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "dev": true, "license": "MIT", "engines": { "node": ">=20" diff --git a/package.json b/package.json index 14e347a8832..f3e1af5e5ca 100644 --- a/package.json +++ b/package.json @@ -174,9 +174,9 @@ "@angular/platform-browser": "21.2.11", "@angular/platform-browser-dynamic": "21.2.11", "@angular/router": "21.2.11", - "@bitwarden/commercial-sdk-internal": "0.2.0-main.798", + "@bitwarden/commercial-sdk-internal": "0.2.0-main.803", "@bitwarden/desktop-napi": "file:apps/desktop/desktop_native/napi", - "@bitwarden/sdk-internal": "0.2.0-main.798", + "@bitwarden/sdk-internal": "0.2.0-main.803", "@electron/fuses": "2.1.1", "@emotion/css": "11.13.5", "@koa/multer": "4.0.0",