diff --git a/apps/web/src/app/tools/send/send-access/default-send-access-service.spec.ts b/apps/web/src/app/tools/send/send-access/default-send-access-service.spec.ts index cd07d3684fb..e0c39a200fc 100644 --- a/apps/web/src/app/tools/send/send-access/default-send-access-service.spec.ts +++ b/apps/web/src/app/tools/send/send-access/default-send-access-service.spec.ts @@ -3,7 +3,9 @@ import { Router, UrlTree } from "@angular/router"; import { mock, MockProxy } from "jest-mock-extended"; import { firstValueFrom, NEVER } from "rxjs"; +import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; import { StateProvider } from "@bitwarden/common/platform/state"; import { mockAccountServiceWith, FakeStateProvider } from "@bitwarden/common/spec"; import { SemanticLogger } from "@bitwarden/common/tools/log"; @@ -17,6 +19,7 @@ import { SEND_RESPONSE_KEY, SEND_CONTEXT_KEY } from "./send-access-memory"; describe("DefaultSendAccessService", () => { let service: DefaultSendAccessService; + let cryptoFunctionService: MockProxy; let stateProvider: FakeStateProvider; let sendApiService: MockProxy; let router: MockProxy; @@ -24,6 +27,9 @@ describe("DefaultSendAccessService", () => { let systemServiceProvider: MockProxy; beforeEach(() => { + jest.resetAllMocks(); + + cryptoFunctionService = mock(); const accountService = mockAccountServiceWith("user-id" as UserId); stateProvider = new FakeStateProvider(accountService); sendApiService = mock(); @@ -36,6 +42,7 @@ describe("DefaultSendAccessService", () => { TestBed.configureTestingModule({ providers: [ DefaultSendAccessService, + { provide: CryptoFunctionService, useValue: cryptoFunctionService }, { provide: StateProvider, useValue: stateProvider }, { provide: SendApiService, useValue: sendApiService }, { provide: Router, useValue: router }, @@ -127,7 +134,6 @@ describe("DefaultSendAccessService", () => { }); it("emits timeout error when API response exceeds 10 seconds", fakeAsync(() => { - // Mock API to never resolve (simulating a hung request) sendApiService.postSendAccess.mockReturnValue(firstValueFrom(NEVER)); const result$ = service.redirect$(sendId); @@ -159,7 +165,6 @@ describe("DefaultSendAccessService", () => { describe("clear", () => { it("sets both SEND_RESPONSE_KEY and SEND_CONTEXT_KEY to null when called", async () => { - // Set initial values await stateProvider.getGlobal(SEND_RESPONSE_KEY).update(() => ({ some: "response" }) as any); await stateProvider.getGlobal(SEND_CONTEXT_KEY).update(() => ({ id: "test", key: "test" })); @@ -172,4 +177,179 @@ describe("DefaultSendAccessService", () => { expect(context).toBeNull(); }); }); + + describe("authenticate$", () => { + const sendId = "test-send-id"; + const password = "test-password"; + + it("returns true and stores response when authentication succeeds", async () => { + const keyArray = new Uint8Array([1, 2, 3, 4, 5]); + const key = Utils.fromBufferToB64(keyArray); + const hashedArray = new Uint8Array([10, 20, 30, 40]); + const expectedPassword = Utils.fromBufferToB64(hashedArray); + const mockResponse = { id: sendId, data: "some data" } as any; + + await stateProvider.getGlobal(SEND_CONTEXT_KEY).update(() => ({ id: sendId, key })); + cryptoFunctionService.pbkdf2.mockResolvedValue(hashedArray); + sendApiService.postSendAccess.mockResolvedValue(mockResponse); + + const result = await firstValueFrom(service.authenticate$(sendId, password)); + + expect(result).toBe(true); + expect(sendApiService.postSendAccess).toHaveBeenCalledWith( + sendId, + expect.objectContaining({ password: expectedPassword }), + ); + + // Verify response is stored in state + const storedResponse = await firstValueFrom( + stateProvider.getGlobal(SEND_RESPONSE_KEY).state$, + ); + expect(storedResponse).toBe(mockResponse); + }); + + it("returns false and logs debug when authentication fails with 401", async () => { + const keyArray = new Uint8Array([1, 2, 3, 4, 5]); + const key = Utils.fromBufferToB64(keyArray); + const hashedArray = new Uint8Array([10, 20, 30, 40]); + const errorResponse = new ErrorResponse([], 401); + await stateProvider.getGlobal(SEND_CONTEXT_KEY).update(() => ({ id: sendId, key })); + cryptoFunctionService.pbkdf2.mockResolvedValue(hashedArray); + sendApiService.postSendAccess.mockRejectedValue(errorResponse); + + const result = await firstValueFrom(service.authenticate$(sendId, password)); + + expect(result).toBe(false); + expect(logger.debug).toHaveBeenCalledWith("received failed authentication response"); + + // Verify state is not updated + const storedResponse = await firstValueFrom( + stateProvider.getGlobal(SEND_RESPONSE_KEY).state$, + ); + expect(storedResponse).toBeNull(); + }); + + it("panics when sendId mismatch occurs", fakeAsync(async () => { + const wrongSendId = "wrong-send-id"; + const keyArray = new Uint8Array([1, 2, 3, 4, 5]); + const key = Utils.fromBufferToB64(keyArray); + await stateProvider.getGlobal(SEND_CONTEXT_KEY).update(() => ({ id: wrongSendId, key })); + logger.panicWhen.mockImplementation((condition, data, message) => { + if (condition) { + throw new Error(`Panic: ${message}`); + } + + return true; + }); + + const result$ = service.authenticate$(sendId, password); + let error: any; + result$.subscribe({ + error: (err: unknown) => (error = err), + }); + + tick(); + + expect(error).toBeDefined(); + expect(error.message).toBe("Panic: unexpected sendId"); + expect(logger.panicWhen).toHaveBeenCalledWith( + true, // condition: sendId !== id (test-send-id !== wrong-send-id) + { expected: sendId, actual: sendId }, + "unexpected sendId", + ); + })); + + it("emits timeout error when context is null", fakeAsync(async () => { + await stateProvider.getGlobal(SEND_CONTEXT_KEY).update(() => null); + + const result$ = service.authenticate$(sendId, password); + let error: any; + + result$.subscribe({ + error: (err: unknown) => (error = err), + }); + + // Advance time past 10 seconds + tick(10001); + + expect(error).toBeDefined(); + expect(error.name).toBe("TimeoutError"); + })); + + it("emits timeout error when context is invalid", fakeAsync(async () => { + // Set context missing required properties + await stateProvider.getGlobal(SEND_CONTEXT_KEY).update(() => ({ missingId: "test" }) as any); + + const result$ = service.authenticate$(sendId, password); + let error: any; + + result$.subscribe({ + error: (err: unknown) => (error = err), + }); + + // Advance time past 10 seconds + tick(10001); + + expect(error).toBeDefined(); + expect(error.name).toBe("TimeoutError"); + })); + + it("throws error and logs when server error occurs", async () => { + const keyArray = new Uint8Array([1, 2, 3, 4, 5]); + const key = Utils.fromBufferToB64(keyArray); + const hashedArray = new Uint8Array([10, 20, 30, 40]); + const errorResponse = new ErrorResponse([], 500); + await stateProvider.getGlobal(SEND_CONTEXT_KEY).update(() => ({ id: sendId, key })); + + // Mock crypto and API + cryptoFunctionService.pbkdf2.mockResolvedValue(hashedArray); + sendApiService.postSendAccess.mockRejectedValue(errorResponse); + + await expect(firstValueFrom(service.authenticate$(sendId, password))).rejects.toBe( + errorResponse, + ); + + expect(logger.error).toHaveBeenCalledWith("an error occurred during authentication"); + }); + + it("throws error and logs when network error occurs", async () => { + const keyArray = new Uint8Array([1, 2, 3, 4, 5]); + const key = Utils.fromBufferToB64(keyArray); + const hashedArray = new Uint8Array([10, 20, 30, 40]); + const networkError = new Error("Network error"); + await stateProvider.getGlobal(SEND_CONTEXT_KEY).update(() => ({ id: sendId, key })); + cryptoFunctionService.pbkdf2.mockResolvedValue(hashedArray); + sendApiService.postSendAccess.mockRejectedValue(networkError); + + await expect(firstValueFrom(service.authenticate$(sendId, password))).rejects.toThrow( + "Network error", + ); + + expect(logger.error).toHaveBeenCalledWith("an error occurred during authentication"); + }); + + it("emits timeout error when API response exceeds 10 seconds", fakeAsync(async () => { + const keyArray = new Uint8Array([1, 2, 3, 4, 5]); + const key = Utils.fromBufferToB64(keyArray); + const hashedArray = new Uint8Array([10, 20, 30, 40]); + await stateProvider.getGlobal(SEND_CONTEXT_KEY).update(() => ({ id: sendId, key })); + + // Mock API to never resolve (simulating a hung request) + sendApiService.postSendAccess.mockReturnValue(firstValueFrom(NEVER)); + cryptoFunctionService.pbkdf2.mockResolvedValue(hashedArray); + + const result$ = service.authenticate$(sendId, password); + let error: any; + + result$.subscribe({ + error: (err: unknown) => (error = err), + }); + + // Advance time past 10 seconds + tick(10001); + + expect(error).toBeDefined(); + expect(error.name).toBe("TimeoutError"); + })); + }); }); diff --git a/apps/web/src/app/tools/send/send-access/default-send-access-service.ts b/apps/web/src/app/tools/send/send-access/default-send-access-service.ts index e67e4d0673c..a2b479aa666 100644 --- a/apps/web/src/app/tools/send/send-access/default-send-access-service.ts +++ b/apps/web/src/app/tools/send/send-access/default-send-access-service.ts @@ -1,7 +1,8 @@ import { Injectable, Inject } from "@angular/core"; import { Router, UrlTree } from "@angular/router"; -import { map, of, from, catchError, timeout } from "rxjs"; +import { map, of, from, catchError, timeout, concatMap, filter, tap } from "rxjs"; +import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { StateProvider } from "@bitwarden/common/platform/state"; import { SemanticLogger } from "@bitwarden/common/tools/log"; @@ -10,7 +11,8 @@ import { SendAccessRequest } from "@bitwarden/common/tools/send/models/request/s import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { SYSTEM_SERVICE_PROVIDER } from "@bitwarden/generator-components"; -import { SEND_RESPONSE_KEY, SEND_CONTEXT_KEY } from "./send-access-memory"; +import { keyToSendAccessRequest } from "./rx"; +import { SEND_RESPONSE_KEY, SEND_CONTEXT_KEY, isSendContext } from "./send-access-memory"; import { SendAccessService } from "./send-access-service.abstraction"; import { isErrorResponse } from "./util"; @@ -21,6 +23,7 @@ export class DefaultSendAccessService implements SendAccessService { private readonly logger: SemanticLogger; constructor( + private readonly crypto: CryptoFunctionService, private readonly state: StateProvider, private readonly api: SendApiService, private readonly router: Router, @@ -60,6 +63,40 @@ export class DefaultSendAccessService implements SendAccessService { return redirect$; } + authenticate$(sendId: string, password: string) { + const authenticate$ = this.state.getGlobal(SEND_CONTEXT_KEY).state$.pipe( + filter(isSendContext), + tap(({ id }) => + this.logger.panicWhen( + sendId !== id, + { expected: sendId, actual: sendId }, + "unexpected sendId", + ), + ), + map(({ key }) => key), + keyToSendAccessRequest(this.crypto, password), + concatMap(async (request) => { + const result = await this.api.postSendAccess(sendId, request); + + await this.state.getGlobal(SEND_RESPONSE_KEY).update(() => result); + + return true; + }), + catchError((error: unknown) => { + if (isErrorResponse(error) && error.statusCode === 401) { + this.logger.debug("received failed authentication response"); + return of(false); + } + + this.logger.error("an error occurred during authentication"); + throw error; + }), + timeout({ first: TEN_SECONDS }), + ); + + return authenticate$; + } + private toViewRedirect(sendId: string) { return this.router.createUrlTree(["send", "content", sendId]); } diff --git a/apps/web/src/app/tools/send/send-access/rx.spec.ts b/apps/web/src/app/tools/send/send-access/rx.spec.ts new file mode 100644 index 00000000000..a315dbb8230 --- /dev/null +++ b/apps/web/src/app/tools/send/send-access/rx.spec.ts @@ -0,0 +1,126 @@ +import { mock } from "jest-mock-extended"; +import { of, firstValueFrom, Subject, toArray } from "rxjs"; + +import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { SendAccessRequest } from "@bitwarden/common/tools/send/models/request/send-access.request"; +import { SEND_KDF_ITERATIONS } from "@bitwarden/common/tools/send/send-kdf"; + +import { awaitAsync } from "../../../../../../../libs/common/spec"; + +import { keyToSendAccessRequest } from "./rx"; + +describe("keyToSendAccessRequest", () => { + const cryptoFunctionService = mock(); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it("calls pbkdf2 with exact parameters when given a base64 key", async () => { + const password = "test-password"; + const keyArray = new Uint8Array([1, 2, 3, 4, 5]); + const key = Utils.fromBufferToB64(keyArray); + const hashedArray = new Uint8Array([10, 20, 30, 40]); + + cryptoFunctionService.pbkdf2.mockResolvedValue(hashedArray); + + const toRequest = keyToSendAccessRequest(cryptoFunctionService, password); + await firstValueFrom(of(key).pipe(toRequest)); + + expect(cryptoFunctionService.pbkdf2).toHaveBeenCalledWith( + password, + keyArray, + "sha256", + SEND_KDF_ITERATIONS, + ); + expect(cryptoFunctionService.pbkdf2).toHaveBeenCalledTimes(1); + }); + + it("returns SendAccessRequest with base64 encoded password when pbkdf2 succeeds", async () => { + const password = "test-password"; + const keyArray = new Uint8Array([1, 2, 3, 4, 5]); + const key = Utils.fromBufferToB64(keyArray); + const hashedArray = new Uint8Array([10, 20, 30, 40]); + const expectedPassword = Utils.fromBufferToB64(hashedArray); + + cryptoFunctionService.pbkdf2.mockResolvedValue(hashedArray); + + const hasher = keyToSendAccessRequest(cryptoFunctionService, password); + const result = await firstValueFrom(of(key).pipe(hasher)); + + expect(result.password).toBe(expectedPassword); + }); + + it("propagates error when pbkdf2 fails", async () => { + const password = "test-password"; + const key = Utils.fromBufferToB64(new Uint8Array([1, 2, 3, 4, 5])); + const expectedError = new Error("pbkdf2 failed"); + + cryptoFunctionService.pbkdf2.mockRejectedValue(expectedError); + + const hasher = keyToSendAccessRequest(cryptoFunctionService, password); + await expect(firstValueFrom(of(key).pipe(hasher))).rejects.toThrow("pbkdf2 failed"); + }); + + it("completes when source observable completes", (done) => { + const password = "test-password"; + const source$ = new Subject(); + const hashedArray = new Uint8Array([10, 20, 30, 40]); + + cryptoFunctionService.pbkdf2.mockResolvedValue(hashedArray); + + const hasher = keyToSendAccessRequest(cryptoFunctionService, password); + source$.pipe(hasher).subscribe({ + complete: () => { + done(); + }, + }); + + // if `hasher` fails to pass through complete, `done()` won't be called + // and the test will time out + source$.complete(); + }); + + it("handles multiple emissions when source emits multiple keys", async () => { + const password = "test-password"; + const keyArray1 = new Uint8Array([1, 2, 3, 4, 5]); + const keyArray2 = new Uint8Array([6, 7, 8, 9, 10]); + const key1 = Utils.fromBufferToB64(keyArray1); + const key2 = Utils.fromBufferToB64(keyArray2); + const hashedArray1 = new Uint8Array([10, 20, 30, 40]); + const hashedArray2 = new Uint8Array([50, 60, 70, 80]); + const expectedPassword1 = Utils.fromBufferToB64(hashedArray1); + const expectedPassword2 = Utils.fromBufferToB64(hashedArray2); + + cryptoFunctionService.pbkdf2 + .mockResolvedValueOnce(hashedArray1) + .mockResolvedValueOnce(hashedArray2); + + const hasher = keyToSendAccessRequest(cryptoFunctionService, password); + let results: SendAccessRequest[] = []; + of(key1, key2) + .pipe(hasher, toArray()) + .subscribe((r) => (results = r)); + await awaitAsync(); + + expect(results).toHaveLength(2); + expect(results[0].password).toBe(expectedPassword1); + expect(results[1].password).toBe(expectedPassword2); + expect(cryptoFunctionService.pbkdf2).toHaveBeenCalledTimes(2); + expect(cryptoFunctionService.pbkdf2).toHaveBeenNthCalledWith( + 1, + password, + keyArray1, + "sha256", + SEND_KDF_ITERATIONS, + ); + expect(cryptoFunctionService.pbkdf2).toHaveBeenNthCalledWith( + 2, + password, + keyArray2, + "sha256", + SEND_KDF_ITERATIONS, + ); + }); +}); diff --git a/apps/web/src/app/tools/send/send-access/rx.ts b/apps/web/src/app/tools/send/send-access/rx.ts new file mode 100644 index 00000000000..bfc07206703 --- /dev/null +++ b/apps/web/src/app/tools/send/send-access/rx.ts @@ -0,0 +1,22 @@ +import { map, concatMap, pipe, OperatorFunction } from "rxjs"; + +import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { SendAccessRequest } from "@bitwarden/common/tools/send/models/request/send-access.request"; +import { SEND_KDF_ITERATIONS } from "@bitwarden/common/tools/send/send-kdf"; + +export function keyToSendAccessRequest( + crypto: CryptoFunctionService, + password: string, +): OperatorFunction { + return pipe( + map((key) => Utils.fromUrlB64ToArray(key)), + // FIXME: support kdf iteration and/or hash updates + concatMap((key) => crypto.pbkdf2(password, key, "sha256", SEND_KDF_ITERATIONS)), + map((hash) => { + const request = new SendAccessRequest(); + request.password = Utils.fromBufferToB64(hash); + return request; + }), + ); +} diff --git a/libs/common/src/tools/log/default-semantic-logger.ts b/libs/common/src/tools/log/default-semantic-logger.ts index eb1ecbe36c6..e90466b4d75 100644 --- a/libs/common/src/tools/log/default-semantic-logger.ts +++ b/libs/common/src/tools/log/default-semantic-logger.ts @@ -48,6 +48,19 @@ export class DefaultSemanticLogger implements SemanticLo throw new Error(panicMessage); } + panicWhen( + when: boolean, + content: Jsonify | string, + message?: string, + ): when is false | never { + if (when) { + // type conversion safe because Jsonify === string + this.panic(content as Jsonify, message); + } + + return !when; + } + private log(content: Jsonify, level: LogLevelType, message?: string) { const log = { ...this.context, diff --git a/libs/common/src/tools/log/semantic-logger.abstraction.ts b/libs/common/src/tools/log/semantic-logger.abstraction.ts index 51aaa917378..c5b4b4f0eea 100644 --- a/libs/common/src/tools/log/semantic-logger.abstraction.ts +++ b/libs/common/src/tools/log/semantic-logger.abstraction.ts @@ -92,4 +92,24 @@ export interface SemanticLogger { /** combined signature for overloaded methods */ panic(content: Jsonify | string, message?: string): never; + + /** Conditionally panics. + * @param when - when this condition is true, the logger panics. + * @param message - a message to record in the log's `message` field. + */ + panicWhen(when: boolean, message: string): when is false | never; + + /** Conditionally panics. + * @param when - when this condition is true, the logger panics. + * @param content - JSON content included in the log's `content` field. + * @param message - a message to record in the log's `message` field. + */ + panicWhen(when: boolean, content: Jsonify, message?: string): when is false | never; + + /** combined signature for overloaded methods */ + panicWhen( + when: boolean, + content: Jsonify | string, + message?: string, + ): when is false | never; }