diff --git a/apps/browser/src/autofill/background/overlay.background.spec.ts b/apps/browser/src/autofill/background/overlay.background.spec.ts index 22531788d37..81e6c538c13 100644 --- a/apps/browser/src/autofill/background/overlay.background.spec.ts +++ b/apps/browser/src/autofill/background/overlay.background.spec.ts @@ -190,7 +190,9 @@ describe("OverlayBackground", () => { inlineMenuFieldQualificationService = new InlineMenuFieldQualificationService(); themeStateService = mock(); themeStateService.selectedTheme$ = selectedThemeMock$; - totpService = mock(); + totpService = mock({ + getCode$: jest.fn().mockReturnValue(of(undefined)), + }); overlayBackground = new OverlayBackground( logService, cipherService, diff --git a/apps/browser/src/autofill/background/overlay.background.ts b/apps/browser/src/autofill/background/overlay.background.ts index 1d55a154ee3..454b12cdcea 100644 --- a/apps/browser/src/autofill/background/overlay.background.ts +++ b/apps/browser/src/autofill/background/overlay.background.ts @@ -707,13 +707,15 @@ export class OverlayBackground implements OverlayBackgroundInterface { }; if (cipher.type === CipherType.Login) { - const totpCode = await this.totpService.getCode(cipher.login?.totp); - const totpCodeTimeInterval = this.totpService.getTimeInterval(cipher.login?.totp); + const totpResponse = cipher.login?.totp + ? await firstValueFrom(this.totpService.getCode$(cipher.login.totp)) + : undefined; + inlineMenuData.login = { username: cipher.login.username, - totp: totpCode, + totp: totpResponse?.code, totpField: this.isTotpFieldForCurrentField(), - totpCodeTimeInterval: totpCodeTimeInterval, + totpCodeTimeInterval: totpResponse?.period, passkey: hasPasskey ? { rpName: cipher.login.fido2Credentials[0].rpName, @@ -1131,9 +1133,13 @@ export class OverlayBackground implements OverlayBackgroundInterface { this.updateLastUsedInlineMenuCipher(inlineMenuCipherId, cipher); if (cipher.login?.totp) { - this.platformUtilsService.copyToClipboard( - await this.totpService.getCode(cipher.login.totp), - ); + const totpResponse = await firstValueFrom(this.totpService.getCode$(cipher.login.totp)); + + if (totpResponse?.code) { + this.platformUtilsService.copyToClipboard(totpResponse.code); + } else { + this.logService.error("Failed to get TOTP code for inline menu cipher"); + } } return; } diff --git a/apps/browser/src/autofill/browser/context-menu-clicked-handler.spec.ts b/apps/browser/src/autofill/browser/context-menu-clicked-handler.spec.ts index c8cb7e81f72..61d6b9dc480 100644 --- a/apps/browser/src/autofill/browser/context-menu-clicked-handler.spec.ts +++ b/apps/browser/src/autofill/browser/context-menu-clicked-handler.spec.ts @@ -1,4 +1,5 @@ import { mock, MockProxy } from "jest-mock-extended"; +import { of } from "rxjs"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; @@ -159,19 +160,25 @@ describe("ContextMenuClickedHandler", () => { it("copies totp code to clipboard", async () => { cipherService.getAllDecrypted.mockResolvedValue([createCipher({ totp: "TEST_TOTP_SEED" })]); - totpService.getCode.mockImplementation((seed) => { + jest.spyOn(totpService, "getCode$").mockImplementation((seed: string) => { if (seed === "TEST_TOTP_SEED") { - return Promise.resolve("123456"); + return of({ + code: "123456", + period: 30, + }); } - return Promise.resolve("654321"); + return of({ + code: "654321", + period: 30, + }); }); await sut.run(createData(`${COPY_VERIFICATION_CODE_ID}_1`, COPY_VERIFICATION_CODE_ID), { url: "https://test.com", } as any); - expect(totpService.getCode).toHaveBeenCalledTimes(1); + expect(totpService.getCode$).toHaveBeenCalledTimes(1); expect(copyToClipboard).toHaveBeenCalledWith({ text: "123456", diff --git a/apps/browser/src/autofill/browser/context-menu-clicked-handler.ts b/apps/browser/src/autofill/browser/context-menu-clicked-handler.ts index 69c8b6e70b8..2fb435a4c67 100644 --- a/apps/browser/src/autofill/browser/context-menu-clicked-handler.ts +++ b/apps/browser/src/autofill/browser/context-menu-clicked-handler.ts @@ -205,8 +205,9 @@ export class ContextMenuClickedHandler { action: COPY_VERIFICATION_CODE_ID, }); } else { + const totpResponse = await firstValueFrom(this.totpService.getCode$(cipher.login.totp)); this.copyToClipboard({ - text: await this.totpService.getCode(cipher.login.totp), + text: totpResponse.code, tab: tab, }); } diff --git a/apps/browser/src/autofill/services/autofill.service.spec.ts b/apps/browser/src/autofill/services/autofill.service.spec.ts index 3843734ad64..7bc66ea322c 100644 --- a/apps/browser/src/autofill/services/autofill.service.spec.ts +++ b/apps/browser/src/autofill/services/autofill.service.spec.ts @@ -921,12 +921,12 @@ describe("AutofillService", () => { .spyOn(billingAccountProfileStateService, "hasPremiumFromAnySource$") .mockImplementation(() => of(true)); jest.spyOn(autofillService, "getShouldAutoCopyTotp").mockResolvedValue(true); - jest.spyOn(totpService, "getCode").mockResolvedValue(totpCode); + totpService.getCode$.mockReturnValue(of({ code: totpCode, period: 30 })); const autofillResult = await autofillService.doAutoFill(autofillOptions); expect(autofillService.getShouldAutoCopyTotp).toHaveBeenCalled(); - expect(totpService.getCode).toHaveBeenCalledWith(autofillOptions.cipher.login.totp); + expect(totpService.getCode$).toHaveBeenCalledWith(autofillOptions.cipher.login.totp); expect(autofillResult).toBe(totpCode); }); @@ -940,7 +940,7 @@ describe("AutofillService", () => { const autofillResult = await autofillService.doAutoFill(autofillOptions); expect(autofillService.getShouldAutoCopyTotp).not.toHaveBeenCalled(); - expect(totpService.getCode).not.toHaveBeenCalled(); + expect(totpService.getCode$).not.toHaveBeenCalled(); expect(autofillResult).toBeNull(); }); @@ -956,12 +956,12 @@ describe("AutofillService", () => { it("returns a null value if the login does not contain a TOTP value", async () => { autofillOptions.cipher.login.totp = undefined; jest.spyOn(autofillService, "getShouldAutoCopyTotp"); - jest.spyOn(totpService, "getCode"); + jest.spyOn(totpService, "getCode$"); const autofillResult = await autofillService.doAutoFill(autofillOptions); expect(autofillService.getShouldAutoCopyTotp).not.toHaveBeenCalled(); - expect(totpService.getCode).not.toHaveBeenCalled(); + expect(totpService.getCode$).not.toHaveBeenCalled(); expect(autofillResult).toBeNull(); }); @@ -984,12 +984,12 @@ describe("AutofillService", () => { .spyOn(billingAccountProfileStateService, "hasPremiumFromAnySource$") .mockImplementation(() => of(true)); jest.spyOn(autofillService, "getShouldAutoCopyTotp").mockResolvedValue(false); - jest.spyOn(totpService, "getCode"); + jest.spyOn(totpService, "getCode$"); const autofillResult = await autofillService.doAutoFill(autofillOptions); expect(autofillService.getShouldAutoCopyTotp).toHaveBeenCalled(); - expect(totpService.getCode).not.toHaveBeenCalled(); + expect(totpService.getCode$).not.toHaveBeenCalled(); expect(autofillResult).toBeNull(); }); }); diff --git a/apps/browser/src/autofill/services/autofill.service.ts b/apps/browser/src/autofill/services/autofill.service.ts index fc7d0ebcc99..72df679294d 100644 --- a/apps/browser/src/autofill/services/autofill.service.ts +++ b/apps/browser/src/autofill/services/autofill.service.ts @@ -494,7 +494,7 @@ export default class AutofillService implements AutofillServiceInterface { const shouldAutoCopyTotp = await this.getShouldAutoCopyTotp(); totp = shouldAutoCopyTotp - ? await this.totpService.getCode(options.cipher.login.totp) + ? (await firstValueFrom(this.totpService.getCode$(options.cipher.login.totp))).code : null; }), ); @@ -992,7 +992,10 @@ export default class AutofillService implements AutofillServiceInterface { } filledFields[t.opid] = t; - let totpValue = await this.totpService.getCode(login.totp); + const totpResponse = await firstValueFrom( + this.totpService.getCode$(options.cipher.login.totp), + ); + let totpValue = totpResponse.code; if (totpValue.length == totps.length) { totpValue = totpValue.charAt(i); } diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 7251dea0580..89244f52ecf 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -978,7 +978,7 @@ export default class MainBackground { this.authService, this.accountService, ); - this.totpService = new TotpService(this.cryptoFunctionService, this.logService); + this.totpService = new TotpService(this.sdkService); this.scriptInjectorService = new BrowserScriptInjectorService( this.domainSettingsService, diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index 9d59cdee36a..d6ef1075cff 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -84,6 +84,7 @@ import { MessagingService as MessagingServiceAbstraction } from "@bitwarden/comm import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SdkClientFactory } from "@bitwarden/common/platform/abstractions/sdk/sdk-client-factory"; import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service"; +import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { AbstractStorageService, @@ -285,7 +286,7 @@ const safeProviders: SafeProvider[] = [ safeProvider({ provide: TotpServiceAbstraction, useClass: TotpService, - deps: [CryptoFunctionService, LogService], + deps: [SdkService], }), safeProvider({ provide: OffscreenDocumentService, diff --git a/apps/cli/src/commands/get.command.ts b/apps/cli/src/commands/get.command.ts index 92c3a8baeaf..eea63fdfc74 100644 --- a/apps/cli/src/commands/get.command.ts +++ b/apps/cli/src/commands/get.command.ts @@ -254,8 +254,8 @@ export class GetCommand extends DownloadCommand { return Response.error("No TOTP available for this login."); } - const totp = await this.totpService.getCode(cipher.login.totp); - if (totp == null) { + const totpResponse = await firstValueFrom(this.totpService.getCode$(cipher.login.totp)); + if (!totpResponse.code) { return Response.error("Couldn't generate TOTP code."); } @@ -276,7 +276,7 @@ export class GetCommand extends DownloadCommand { } } - const res = new StringResponse(totp); + const res = new StringResponse(totpResponse.code); return Response.success(res); } diff --git a/apps/cli/src/service-container/service-container.ts b/apps/cli/src/service-container/service-container.ts index 67237e46f33..0e776375e6a 100644 --- a/apps/cli/src/service-container/service-container.ts +++ b/apps/cli/src/service-container/service-container.ts @@ -766,7 +766,7 @@ export class ServiceContainer { this.stateProvider, ); - this.totpService = new TotpService(this.cryptoFunctionService, this.logService); + this.totpService = new TotpService(this.sdkService); this.importApiService = new ImportApiService(this.apiService); diff --git a/apps/desktop/src/vault/app/vault/vault.component.ts b/apps/desktop/src/vault/app/vault/vault.component.ts index 6f844a7bf51..2c669e388f8 100644 --- a/apps/desktop/src/vault/app/vault/vault.component.ts +++ b/apps/desktop/src/vault/app/vault/vault.component.ts @@ -208,8 +208,8 @@ export class VaultComponent implements OnInit, OnDestroy { tCipher.login.hasTotp && this.userHasPremiumAccess ) { - const value = await this.totpService.getCode(tCipher.login.totp); - this.copyValue(tCipher, value, "verificationCodeTotp", "TOTP"); + const value = await firstValueFrom(this.totpService.getCode$(tCipher.login.totp)); + this.copyValue(tCipher, value.code, "verificationCodeTotp", "TOTP"); } break; } @@ -382,8 +382,8 @@ export class VaultComponent implements OnInit, OnDestroy { menu.push({ label: this.i18nService.t("copyVerificationCodeTotp"), click: async () => { - const value = await this.totpService.getCode(cipher.login.totp); - this.copyValue(cipher, value, "verificationCodeTotp", "TOTP"); + const value = await firstValueFrom(this.totpService.getCode$(cipher.login.totp)); + this.copyValue(cipher, value.code, "verificationCodeTotp", "TOTP"); }, }); } diff --git a/apps/desktop/src/vault/app/vault/view.component.html b/apps/desktop/src/vault/app/vault/view.component.html index 59e609312d7..92bd64395eb 100644 --- a/apps/desktop/src/vault/app/vault/view.component.html +++ b/apps/desktop/src/vault/app/vault/view.component.html @@ -128,54 +128,57 @@ {{ "typePasskey" | i18n }} {{ fido2CredentialCreationDateValue }} -
-
- {{ "verificationCodeTotp" | i18n }} - {{ totpCodeFormatted }} -
- -
- + {{ totpInfo.totpCodeFormatted }} +
+ +
+ +
- + +
{{ "verificationCodeTotp" | i18n }} diff --git a/apps/web/src/app/admin-console/organizations/collections/vault.component.ts b/apps/web/src/app/admin-console/organizations/collections/vault.component.ts index 3063805b7e2..2ace66f2364 100644 --- a/apps/web/src/app/admin-console/organizations/collections/vault.component.ts +++ b/apps/web/src/app/admin-console/organizations/collections/vault.component.ts @@ -1177,7 +1177,8 @@ export class VaultComponent implements OnInit, OnDestroy { typeI18nKey = "password"; } else if (field === "totp") { aType = "TOTP"; - value = await this.totpService.getCode(cipher.login.totp); + const totpResponse = await firstValueFrom(this.totpService.getCode$(cipher.login.totp)); + value = totpResponse?.code; typeI18nKey = "verificationCodeTotp"; } else { this.toastService.showToast({ diff --git a/apps/web/src/app/vault/individual-vault/add-edit.component.ts b/apps/web/src/app/vault/individual-vault/add-edit.component.ts index a794687c45b..3df2b9a83c9 100644 --- a/apps/web/src/app/vault/individual-vault/add-edit.component.ts +++ b/apps/web/src/app/vault/individual-vault/add-edit.component.ts @@ -135,12 +135,15 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On if (this.showTotp()) { await this.totpUpdateCode(); - const interval = this.totpService.getTimeInterval(this.cipher.login.totp); - await this.totpTick(interval); - - this.totpInterval = window.setInterval(async () => { + const totpResponse = await firstValueFrom(this.totpService.getCode$(this.cipher.login.totp)); + if (totpResponse) { + const interval = totpResponse.period; await this.totpTick(interval); - }, 1000); + + this.totpInterval = window.setInterval(async () => { + await this.totpTick(interval); + }, 1000); + } } this.cardIsExpired = isCardExpired(this.cipher.card); @@ -273,7 +276,8 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On return; } - this.totpCode = await this.totpService.getCode(this.cipher.login.totp); + const totpResponse = await firstValueFrom(this.totpService.getCode$(this.cipher.login.totp)); + this.totpCode = totpResponse?.code; if (this.totpCode != null) { if (this.totpCode.length > 4) { const half = Math.floor(this.totpCode.length / 2); diff --git a/apps/web/src/app/vault/individual-vault/vault.component.ts b/apps/web/src/app/vault/individual-vault/vault.component.ts index ec461fbbe62..674af0cfa32 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault.component.ts @@ -1146,7 +1146,8 @@ export class VaultComponent implements OnInit, OnDestroy { typeI18nKey = "password"; } else if (field === "totp") { aType = "TOTP"; - value = await this.totpService.getCode(cipher.login.totp); + const totpResponse = await firstValueFrom(this.totpService.getCode$(cipher.login.totp)); + value = totpResponse.code; typeI18nKey = "verificationCodeTotp"; } else { this.toastService.showToast({ diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index dc6c049101a..4d53e1e0bea 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -603,7 +603,7 @@ const safeProviders: SafeProvider[] = [ safeProvider({ provide: TotpServiceAbstraction, useClass: TotpService, - deps: [CryptoFunctionServiceAbstraction, LogService], + deps: [SdkService], }), safeProvider({ provide: TokenServiceAbstraction, diff --git a/libs/angular/src/vault/components/view.component.ts b/libs/angular/src/vault/components/view.component.ts index 92a231ab8db..a2285e6a835 100644 --- a/libs/angular/src/vault/components/view.component.ts +++ b/libs/angular/src/vault/components/view.component.ts @@ -41,6 +41,7 @@ import { AttachmentView } from "@bitwarden/common/vault/models/view/attachment.v import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; +import { TotpInfo } from "@bitwarden/common/vault/services/totp.service"; import { DialogService, ToastService } from "@bitwarden/components"; import { KeyService } from "@bitwarden/key-management"; import { PasswordRepromptService } from "@bitwarden/vault"; @@ -66,20 +67,19 @@ export class ViewComponent implements OnDestroy, OnInit { showPrivateKey: boolean; canAccessPremium: boolean; showPremiumRequiredTotp: boolean; - totpCode: string; - totpCodeFormatted: string; - totpDash: number; - totpSec: number; - totpLow: boolean; fieldType = FieldType; checkPasswordPromise: Promise; folder: FolderView; cipherType = CipherType; - private totpInterval: any; private previousCipherId: string; private passwordReprompted = false; + /** + * Represents TOTP information including display formatting and timing + */ + protected totpInfo$: Observable | undefined; + get fido2CredentialCreationDateValue(): string { const dateCreated = this.i18nService.t("dateCreated"); const creationDate = this.datePipe.transform( @@ -166,19 +166,33 @@ export class ViewComponent implements OnDestroy, OnInit { ).find((f) => f.id == this.cipher.folderId); } - if ( + const canGenerateTotp = this.cipher.type === CipherType.Login && this.cipher.login.totp && - (this.cipher.organizationUseTotp || this.canAccessPremium) - ) { - await this.totpUpdateCode(); - const interval = this.totpService.getTimeInterval(this.cipher.login.totp); - await this.totpTick(interval); + (this.cipher.organizationUseTotp || this.canAccessPremium); - this.totpInterval = setInterval(async () => { - await this.totpTick(interval); - }, 1000); - } + this.totpInfo$ = canGenerateTotp + ? this.totpService.getCode$(this.cipher.login.totp).pipe( + map((response) => { + const epoch = Math.round(new Date().getTime() / 1000.0); + const mod = epoch % response.period; + + // Format code + const totpCodeFormatted = + response.code.length > 4 + ? `${response.code.slice(0, Math.floor(response.code.length / 2))} ${response.code.slice(Math.floor(response.code.length / 2))}` + : response.code; + + return { + totpCode: response.code, + totpCodeFormatted, + totpDash: +(Math.round(((78.6 / response.period) * mod + "e+2") as any) + "e-2"), + totpSec: response.period - mod, + totpLow: response.period - mod <= 7, + } as TotpInfo; + }), + ) + : undefined; if (this.previousCipherId !== this.cipherId) { // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. @@ -515,56 +529,11 @@ export class ViewComponent implements OnDestroy, OnInit { } private cleanUp() { - this.totpCode = null; this.cipher = null; this.folder = null; this.showPassword = false; this.showCardNumber = false; this.showCardCode = false; this.passwordReprompted = false; - if (this.totpInterval) { - clearInterval(this.totpInterval); - } - } - - private async totpUpdateCode() { - if ( - this.cipher == null || - this.cipher.type !== CipherType.Login || - this.cipher.login.totp == null - ) { - if (this.totpInterval) { - clearInterval(this.totpInterval); - } - return; - } - - this.totpCode = await this.totpService.getCode(this.cipher.login.totp); - if (this.totpCode != null) { - if (this.totpCode.length > 4) { - const half = Math.floor(this.totpCode.length / 2); - this.totpCodeFormatted = - this.totpCode.substring(0, half) + " " + this.totpCode.substring(half); - } else { - this.totpCodeFormatted = this.totpCode; - } - } else { - this.totpCodeFormatted = null; - if (this.totpInterval) { - clearInterval(this.totpInterval); - } - } - } - - private async totpTick(intervalSeconds: number) { - const epoch = Math.round(new Date().getTime() / 1000.0); - const mod = epoch % intervalSeconds; - - this.totpSec = intervalSeconds - mod; - this.totpDash = +(Math.round(((78.6 / intervalSeconds) * mod + "e+2") as any) + "e-2"); - this.totpLow = this.totpSec <= 7; - if (mod === 0) { - await this.totpUpdateCode(); - } } } diff --git a/libs/common/src/vault/abstractions/totp.service.ts b/libs/common/src/vault/abstractions/totp.service.ts index af4409a15a6..f07b84e3bd2 100644 --- a/libs/common/src/vault/abstractions/totp.service.ts +++ b/libs/common/src/vault/abstractions/totp.service.ts @@ -1,6 +1,15 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore +import { Observable } from "rxjs"; + +import { TotpResponse } from "@bitwarden/sdk-internal"; + export abstract class TotpService { - getCode: (key: string) => Promise; - getTimeInterval: (key: string) => number; + /** + * Gets an observable that emits TOTP codes at regular intervals + * @param key - Can be: + * - A base32 encoded string + * - OTP Auth URI + * - Steam URI + * @returns Observable that emits TotpResponse containing the code and period + */ + abstract getCode$(key: string): Observable; } diff --git a/libs/common/src/vault/services/totp.service.spec.ts b/libs/common/src/vault/services/totp.service.spec.ts index 71e3ce80b18..c653b4ce1db 100644 --- a/libs/common/src/vault/services/totp.service.spec.ts +++ b/libs/common/src/vault/services/totp.service.spec.ts @@ -1,17 +1,39 @@ import { mock } from "jest-mock-extended"; +import { of, take } from "rxjs"; -import { LogService } from "../../platform/abstractions/log.service"; -import { WebCryptoFunctionService } from "../../platform/services/web-crypto-function.service"; +import { BitwardenClient, TotpResponse } from "@bitwarden/sdk-internal"; + +import { SdkService } from "../../platform/abstractions/sdk/sdk.service"; import { TotpService } from "./totp.service"; describe("TotpService", () => { let totpService: TotpService; + let generateTotpMock: jest.Mock; - const logService = mock(); + const sdkService = mock(); beforeEach(() => { - totpService = new TotpService(new WebCryptoFunctionService(global), logService); + generateTotpMock = jest + .fn() + .mockReturnValueOnce({ + code: "123456", + period: 30, + }) + .mockReturnValueOnce({ code: "654321", period: 30 }) + .mockReturnValueOnce({ code: "567892", period: 30 }); + + const mockBitwardenClient = { + vault: () => ({ + totp: () => ({ + generate_totp: generateTotpMock, + }), + }), + }; + + sdkService.client$ = of(mockBitwardenClient as unknown as BitwardenClient); + + totpService = new TotpService(sdkService); // TOTP is time-based, so we need to mock the current time jest.useFakeTimers({ @@ -24,40 +46,50 @@ describe("TotpService", () => { jest.useRealTimers(); }); - it("should return null if key is null", async () => { - const result = await totpService.getCode(null); - expect(result).toBeNull(); - }); + describe("getCode$", () => { + it("should emit TOTP response when key is provided", (done) => { + totpService + .getCode$("WQIQ25BRKZYCJVYP") + .pipe(take(1)) + .subscribe((result) => { + expect(result).toEqual({ code: "123456", period: 30 }); + done(); + }); - it("should return a code if key is not null", async () => { - const result = await totpService.getCode("WQIQ25BRKZYCJVYP"); - expect(result).toBe("194506"); - }); + jest.advanceTimersByTime(1000); + }); - it("should handle otpauth keys", async () => { - const key = "otpauth://totp/test-account?secret=WQIQ25BRKZYCJVYP"; - const result = await totpService.getCode(key); - expect(result).toBe("194506"); + it("should emit TOTP response every second", () => { + const responses: TotpResponse[] = []; - const period = totpService.getTimeInterval(key); - expect(period).toBe(30); - }); + totpService + .getCode$("WQIQ25BRKZYCJVYP") + .pipe(take(3)) + .subscribe((result) => { + responses.push(result); + }); - it("should handle otpauth different period", async () => { - const key = "otpauth://totp/test-account?secret=WQIQ25BRKZYCJVYP&period=60"; - const result = await totpService.getCode(key); - expect(result).toBe("730364"); + jest.advanceTimersByTime(2000); - const period = totpService.getTimeInterval(key); - expect(period).toBe(60); - }); + expect(responses).toEqual([ + { code: "123456", period: 30 }, + { code: "654321", period: 30 }, + { code: "567892", period: 30 }, + ]); + }); - it("should handle steam keys", async () => { - const key = "steam://HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ"; - const result = await totpService.getCode(key); - expect(result).toBe("7W6CJ"); + it("should stop emitting TOTP response after unsubscribing", () => { + const responses: TotpResponse[] = []; - const period = totpService.getTimeInterval(key); - expect(period).toBe(30); + const subscription = totpService.getCode$("WQIQ25BRKZYCJVYP").subscribe((result) => { + responses.push(result); + }); + + jest.advanceTimersByTime(1000); + subscription.unsubscribe(); + jest.advanceTimersByTime(1000); + + expect(responses).toHaveLength(2); + }); }); }); diff --git a/libs/common/src/vault/services/totp.service.ts b/libs/common/src/vault/services/totp.service.ts index b66e4a1bcf0..3f09462a2c5 100644 --- a/libs/common/src/vault/services/totp.service.ts +++ b/libs/common/src/vault/services/totp.service.ts @@ -1,170 +1,43 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { CryptoFunctionService } from "../../platform/abstractions/crypto-function.service"; -import { LogService } from "../../platform/abstractions/log.service"; -import { Utils } from "../../platform/misc/utils"; +import { Observable, map, shareReplay, switchMap, timer } from "rxjs"; + +import { TotpResponse } from "@bitwarden/sdk-internal"; + +import { SdkService } from "../../platform/abstractions/sdk/sdk.service"; import { TotpService as TotpServiceAbstraction } from "../abstractions/totp.service"; -const B32Chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; -const SteamChars = "23456789BCDFGHJKMNPQRTVWXY"; +/** + * Represents TOTP information including display formatting and timing + */ +export type TotpInfo = { + /** The TOTP code value */ + totpCode: string; + + /** The TOTP code value formatted for display, includes spaces */ + totpCodeFormatted: string; + + /** Progress bar percentage value */ + totpDash: number; + + /** Seconds remaining until the TOTP code changes */ + totpSec: number; + + /** Indicates when the code is close to expiring */ + totpLow: boolean; +}; export class TotpService implements TotpServiceAbstraction { - constructor( - private cryptoFunctionService: CryptoFunctionService, - private logService: LogService, - ) {} + constructor(private sdkService: SdkService) {} - async getCode(key: string): Promise { - if (key == null) { - return null; - } - let period = 30; - let alg: "sha1" | "sha256" | "sha512" = "sha1"; - let digits = 6; - let keyB32 = key; - const isOtpAuth = key.toLowerCase().indexOf("otpauth://") === 0; - const isSteamAuth = !isOtpAuth && key.toLowerCase().indexOf("steam://") === 0; - if (isOtpAuth) { - const params = Utils.getQueryParams(key); - if (params.has("digits") && params.get("digits") != null) { - try { - const digitParams = parseInt(params.get("digits").trim(), null); - if (digitParams > 10) { - digits = 10; - } else if (digitParams > 0) { - digits = digitParams; - } - } catch { - this.logService.error("Invalid digits param."); - } - } - if (params.has("period") && params.get("period") != null) { - try { - const periodParam = parseInt(params.get("period").trim(), null); - if (periodParam > 0) { - period = periodParam; - } - } catch { - this.logService.error("Invalid period param."); - } - } - if (params.has("secret") && params.get("secret") != null) { - keyB32 = params.get("secret"); - } - if (params.has("algorithm") && params.get("algorithm") != null) { - const algParam = params.get("algorithm").toLowerCase(); - if (algParam === "sha1" || algParam === "sha256" || algParam === "sha512") { - alg = algParam; - } - } - } else if (isSteamAuth) { - keyB32 = key.substr("steam://".length); - digits = 5; - } - - const epoch = Math.round(new Date().getTime() / 1000.0); - const timeHex = this.leftPad(this.decToHex(Math.floor(epoch / period)), 16, "0"); - const timeBytes = Utils.fromHexToArray(timeHex); - const keyBytes = this.b32ToBytes(keyB32); - - if (!keyBytes.length || !timeBytes.length) { - return null; - } - - const hash = await this.sign(keyBytes, timeBytes, alg); - if (hash.length === 0) { - return null; - } - - const offset = hash[hash.length - 1] & 0xf; - const binary = - ((hash[offset] & 0x7f) << 24) | - ((hash[offset + 1] & 0xff) << 16) | - ((hash[offset + 2] & 0xff) << 8) | - (hash[offset + 3] & 0xff); - - let otp = ""; - if (isSteamAuth) { - let fullCode = binary & 0x7fffffff; - for (let i = 0; i < digits; i++) { - otp += SteamChars[fullCode % SteamChars.length]; - fullCode = Math.trunc(fullCode / SteamChars.length); - } - } else { - otp = (binary % Math.pow(10, digits)).toString(); - otp = this.leftPad(otp, digits, "0"); - } - - return otp; - } - - getTimeInterval(key: string): number { - let period = 30; - if (key != null && key.toLowerCase().indexOf("otpauth://") === 0) { - const params = Utils.getQueryParams(key); - if (params.has("period") && params.get("period") != null) { - try { - period = parseInt(params.get("period").trim(), null); - } catch { - this.logService.error("Invalid period param."); - } - } - } - return period; - } - - // Helpers - - private leftPad(s: string, l: number, p: string): string { - if (l + 1 >= s.length) { - s = Array(l + 1 - s.length).join(p) + s; - } - return s; - } - - private decToHex(d: number): string { - return (d < 15.5 ? "0" : "") + Math.round(d).toString(16); - } - - private b32ToHex(s: string): string { - s = s.toUpperCase(); - let cleanedInput = ""; - - for (let i = 0; i < s.length; i++) { - if (B32Chars.indexOf(s[i]) < 0) { - continue; - } - - cleanedInput += s[i]; - } - s = cleanedInput; - - let bits = ""; - let hex = ""; - for (let i = 0; i < s.length; i++) { - const byteIndex = B32Chars.indexOf(s.charAt(i)); - if (byteIndex < 0) { - continue; - } - bits += this.leftPad(byteIndex.toString(2), 5, "0"); - } - for (let i = 0; i + 4 <= bits.length; i += 4) { - const chunk = bits.substr(i, 4); - hex = hex + parseInt(chunk, 2).toString(16); - } - return hex; - } - - private b32ToBytes(s: string): Uint8Array { - return Utils.fromHexToArray(this.b32ToHex(s)); - } - - private async sign( - keyBytes: Uint8Array, - timeBytes: Uint8Array, - alg: "sha1" | "sha256" | "sha512", - ) { - const signature = await this.cryptoFunctionService.hmac(timeBytes, keyBytes, alg); - return new Uint8Array(signature); + getCode$(key: string): Observable { + return timer(0, 1000).pipe( + switchMap(() => + this.sdkService.client$.pipe( + map((sdk) => { + return sdk.vault().totp().generate_totp(key); + }), + ), + ), + shareReplay({ refCount: true, bufferSize: 1 }), + ); } } diff --git a/libs/vault/src/components/totp-countdown/totp-countdown.component.html b/libs/vault/src/components/totp-countdown/totp-countdown.component.html index 5c535a9e270..affe97d734f 100644 --- a/libs/vault/src/components/totp-countdown/totp-countdown.component.html +++ b/libs/vault/src/components/totp-countdown/totp-countdown.component.html @@ -1,34 +1,42 @@ -
- -
+ +
+ +
+
diff --git a/libs/vault/src/components/totp-countdown/totp-countdown.component.ts b/libs/vault/src/components/totp-countdown/totp-countdown.component.ts index 17b6cd98c25..08587cbb9fa 100644 --- a/libs/vault/src/components/totp-countdown/totp-countdown.component.ts +++ b/libs/vault/src/components/totp-countdown/totp-countdown.component.ts @@ -2,9 +2,11 @@ // @ts-strict-ignore import { CommonModule } from "@angular/common"; import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core"; +import { Observable, map, tap } from "rxjs"; import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { TotpInfo } from "@bitwarden/common/vault/services/totp.service"; import { TypographyModule } from "@bitwarden/components"; @Component({ @@ -17,69 +19,45 @@ export class BitTotpCountdownComponent implements OnInit { @Input() cipher: CipherView; @Output() sendCopyCode = new EventEmitter(); - totpCode: string; - totpCodeFormatted: string; - totpDash: number; - totpSec: number; - totpLow: boolean; - private totpInterval: any; + /** + * Represents TOTP information including display formatting and timing + */ + totpInfo$: Observable | undefined; constructor(protected totpService: TotpService) {} async ngOnInit() { - await this.totpUpdateCode(); - const interval = this.totpService.getTimeInterval(this.cipher.login.totp); - await this.totpTick(interval); + this.totpInfo$ = this.cipher?.login?.totp + ? this.totpService.getCode$(this.cipher.login.totp).pipe( + map((response) => { + const epoch = Math.round(new Date().getTime() / 1000.0); + const mod = epoch % response.period; - this.totpInterval = setInterval(async () => { - await this.totpTick(interval); - }, 1000); + return { + totpCode: response.code, + totpCodeFormatted: this.formatTotpCode(response.code), + totpSec: response.period - mod, + totpDash: +(Math.round(((60 / response.period) * mod + "e+2") as any) + "e-2"), + totpLow: response.period - mod <= 7, + } as TotpInfo; + }), + tap((totpInfo) => { + if (totpInfo.totpCode && totpInfo.totpCode.length > 4) { + this.sendCopyCode.emit({ + totpCode: totpInfo.totpCode, + totpCodeFormatted: totpInfo.totpCodeFormatted, + }); + } + }), + ) + : undefined; } - private async totpUpdateCode() { - if (this.cipher.login.totp == null) { - this.clearTotp(); - return; - } - - this.totpCode = await this.totpService.getCode(this.cipher.login.totp); - if (this.totpCode != null) { - if (this.totpCode.length > 4) { - this.totpCodeFormatted = this.formatTotpCode(); - this.sendCopyCode.emit({ - totpCode: this.totpCode, - totpCodeFormatted: this.totpCodeFormatted, - }); - } else { - this.totpCodeFormatted = this.totpCode; - } - } else { - this.totpCodeFormatted = null; - this.sendCopyCode.emit({ totpCode: null, totpCodeFormatted: null }); - this.clearTotp(); - } - } - - private async totpTick(intervalSeconds: number) { - const epoch = Math.round(new Date().getTime() / 1000.0); - const mod = epoch % intervalSeconds; - - this.totpSec = intervalSeconds - mod; - this.totpDash = +(Math.round(((60 / intervalSeconds) * mod + "e+2") as any) + "e-2"); - this.totpLow = this.totpSec <= 7; - if (mod === 0) { - await this.totpUpdateCode(); - } - } - - private formatTotpCode(): string { - const half = Math.floor(this.totpCode.length / 2); - return this.totpCode.substring(0, half) + " " + this.totpCode.substring(half); - } - - private clearTotp() { - if (this.totpInterval) { - clearInterval(this.totpInterval); + private formatTotpCode(code: string): string { + if (code.length > 4) { + const half = Math.floor(code.length / 2); + return code.substring(0, half) + " " + code.substring(half); } + return code; } } diff --git a/libs/vault/src/services/copy-cipher-field.service.spec.ts b/libs/vault/src/services/copy-cipher-field.service.spec.ts index 5a273c0828f..5b038376aee 100644 --- a/libs/vault/src/services/copy-cipher-field.service.spec.ts +++ b/libs/vault/src/services/copy-cipher-field.service.spec.ts @@ -136,10 +136,10 @@ describe("CopyCipherFieldService", () => { it("should get TOTP code when allowed from premium", async () => { billingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(true)); - totpService.getCode.mockResolvedValue("123456"); + totpService.getCode$.mockReturnValue(of({ code: "123456", period: 30 })); const result = await service.copy(valueToCopy, actionType, cipher, skipReprompt); expect(result).toBeTruthy(); - expect(totpService.getCode).toHaveBeenCalledWith(valueToCopy); + expect(totpService.getCode$).toHaveBeenCalledWith(valueToCopy); expect(platformUtilsService.copyToClipboard).toHaveBeenCalledWith("123456"); expect(billingAccountProfileStateService.hasPremiumFromAnySource$).toHaveBeenCalledWith( userId, @@ -148,10 +148,10 @@ describe("CopyCipherFieldService", () => { it("should get TOTP code when allowed from organization", async () => { cipher.organizationUseTotp = true; - totpService.getCode.mockResolvedValue("123456"); + totpService.getCode$.mockReturnValue(of({ code: "123456", period: 30 })); const result = await service.copy(valueToCopy, actionType, cipher, skipReprompt); expect(result).toBeTruthy(); - expect(totpService.getCode).toHaveBeenCalledWith(valueToCopy); + expect(totpService.getCode$).toHaveBeenCalledWith(valueToCopy); expect(platformUtilsService.copyToClipboard).toHaveBeenCalledWith("123456"); }); @@ -159,7 +159,7 @@ describe("CopyCipherFieldService", () => { billingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(false)); const result = await service.copy(valueToCopy, actionType, cipher, skipReprompt); expect(result).toBeFalsy(); - expect(totpService.getCode).not.toHaveBeenCalled(); + expect(totpService.getCode$).not.toHaveBeenCalled(); expect(platformUtilsService.copyToClipboard).not.toHaveBeenCalled(); expect(billingAccountProfileStateService.hasPremiumFromAnySource$).toHaveBeenCalledWith( userId, @@ -170,7 +170,7 @@ describe("CopyCipherFieldService", () => { cipher.login.totp = null; const result = await service.copy(valueToCopy, actionType, cipher, skipReprompt); expect(result).toBeFalsy(); - expect(totpService.getCode).not.toHaveBeenCalled(); + expect(totpService.getCode$).not.toHaveBeenCalled(); expect(platformUtilsService.copyToClipboard).not.toHaveBeenCalled(); }); }); diff --git a/libs/vault/src/services/copy-cipher-field.service.ts b/libs/vault/src/services/copy-cipher-field.service.ts index 2805f3e7541..3f94b27cef8 100644 --- a/libs/vault/src/services/copy-cipher-field.service.ts +++ b/libs/vault/src/services/copy-cipher-field.service.ts @@ -124,7 +124,11 @@ export class CopyCipherFieldService { if (!(await this.totpAllowed(cipher))) { return false; } - valueToCopy = await this.totpService.getCode(valueToCopy); + const totpResponse = await firstValueFrom(this.totpService.getCode$(valueToCopy)); + if (!totpResponse?.code) { + return false; + } + valueToCopy = totpResponse.code; } this.platformUtilsService.copyToClipboard(valueToCopy);