1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-12 14:23:32 +00:00

[PM-11941] Migrate TOTP Generator to use SDK (#12987)

* Refactored totp service to use sdk

Fixed strict typescript issues

* Fixed dependency issues

* Returned object that contains code and period, removed get interval function

* removed dependencies

* Updated to use refactored totp service

* removed sdk service undefined check

* removed undefined as an input from the getCode function

* Made getcode$ an observable

* refactored to use getcodee$

* Filter out emmissions

* updated sdk version

* Fixed readability nit

* log error on overlay if totp response does not return a code

* fix(totpGeneration): [PM-11941] Totp countdown not working on clients

* Used optional chaining if totpresponse returns null or undefined
This commit is contained in:
SmithThe4th
2025-03-06 14:01:07 -05:00
committed by GitHub
parent 1415041fd7
commit e327816bc4
24 changed files with 345 additions and 443 deletions

View File

@@ -190,7 +190,9 @@ describe("OverlayBackground", () => {
inlineMenuFieldQualificationService = new InlineMenuFieldQualificationService(); inlineMenuFieldQualificationService = new InlineMenuFieldQualificationService();
themeStateService = mock<ThemeStateService>(); themeStateService = mock<ThemeStateService>();
themeStateService.selectedTheme$ = selectedThemeMock$; themeStateService.selectedTheme$ = selectedThemeMock$;
totpService = mock<TotpService>(); totpService = mock<TotpService>({
getCode$: jest.fn().mockReturnValue(of(undefined)),
});
overlayBackground = new OverlayBackground( overlayBackground = new OverlayBackground(
logService, logService,
cipherService, cipherService,

View File

@@ -707,13 +707,15 @@ export class OverlayBackground implements OverlayBackgroundInterface {
}; };
if (cipher.type === CipherType.Login) { if (cipher.type === CipherType.Login) {
const totpCode = await this.totpService.getCode(cipher.login?.totp); const totpResponse = cipher.login?.totp
const totpCodeTimeInterval = this.totpService.getTimeInterval(cipher.login?.totp); ? await firstValueFrom(this.totpService.getCode$(cipher.login.totp))
: undefined;
inlineMenuData.login = { inlineMenuData.login = {
username: cipher.login.username, username: cipher.login.username,
totp: totpCode, totp: totpResponse?.code,
totpField: this.isTotpFieldForCurrentField(), totpField: this.isTotpFieldForCurrentField(),
totpCodeTimeInterval: totpCodeTimeInterval, totpCodeTimeInterval: totpResponse?.period,
passkey: hasPasskey passkey: hasPasskey
? { ? {
rpName: cipher.login.fido2Credentials[0].rpName, rpName: cipher.login.fido2Credentials[0].rpName,
@@ -1131,9 +1133,13 @@ export class OverlayBackground implements OverlayBackgroundInterface {
this.updateLastUsedInlineMenuCipher(inlineMenuCipherId, cipher); this.updateLastUsedInlineMenuCipher(inlineMenuCipherId, cipher);
if (cipher.login?.totp) { if (cipher.login?.totp) {
this.platformUtilsService.copyToClipboard( const totpResponse = await firstValueFrom(this.totpService.getCode$(cipher.login.totp));
await 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; return;
} }

View File

@@ -1,4 +1,5 @@
import { mock, MockProxy } from "jest-mock-extended"; import { mock, MockProxy } from "jest-mock-extended";
import { of } from "rxjs";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
@@ -159,19 +160,25 @@ describe("ContextMenuClickedHandler", () => {
it("copies totp code to clipboard", async () => { it("copies totp code to clipboard", async () => {
cipherService.getAllDecrypted.mockResolvedValue([createCipher({ totp: "TEST_TOTP_SEED" })]); cipherService.getAllDecrypted.mockResolvedValue([createCipher({ totp: "TEST_TOTP_SEED" })]);
totpService.getCode.mockImplementation((seed) => { jest.spyOn(totpService, "getCode$").mockImplementation((seed: string) => {
if (seed === "TEST_TOTP_SEED") { 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), { await sut.run(createData(`${COPY_VERIFICATION_CODE_ID}_1`, COPY_VERIFICATION_CODE_ID), {
url: "https://test.com", url: "https://test.com",
} as any); } as any);
expect(totpService.getCode).toHaveBeenCalledTimes(1); expect(totpService.getCode$).toHaveBeenCalledTimes(1);
expect(copyToClipboard).toHaveBeenCalledWith({ expect(copyToClipboard).toHaveBeenCalledWith({
text: "123456", text: "123456",

View File

@@ -205,8 +205,9 @@ export class ContextMenuClickedHandler {
action: COPY_VERIFICATION_CODE_ID, action: COPY_VERIFICATION_CODE_ID,
}); });
} else { } else {
const totpResponse = await firstValueFrom(this.totpService.getCode$(cipher.login.totp));
this.copyToClipboard({ this.copyToClipboard({
text: await this.totpService.getCode(cipher.login.totp), text: totpResponse.code,
tab: tab, tab: tab,
}); });
} }

View File

@@ -921,12 +921,12 @@ describe("AutofillService", () => {
.spyOn(billingAccountProfileStateService, "hasPremiumFromAnySource$") .spyOn(billingAccountProfileStateService, "hasPremiumFromAnySource$")
.mockImplementation(() => of(true)); .mockImplementation(() => of(true));
jest.spyOn(autofillService, "getShouldAutoCopyTotp").mockResolvedValue(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); const autofillResult = await autofillService.doAutoFill(autofillOptions);
expect(autofillService.getShouldAutoCopyTotp).toHaveBeenCalled(); expect(autofillService.getShouldAutoCopyTotp).toHaveBeenCalled();
expect(totpService.getCode).toHaveBeenCalledWith(autofillOptions.cipher.login.totp); expect(totpService.getCode$).toHaveBeenCalledWith(autofillOptions.cipher.login.totp);
expect(autofillResult).toBe(totpCode); expect(autofillResult).toBe(totpCode);
}); });
@@ -940,7 +940,7 @@ describe("AutofillService", () => {
const autofillResult = await autofillService.doAutoFill(autofillOptions); const autofillResult = await autofillService.doAutoFill(autofillOptions);
expect(autofillService.getShouldAutoCopyTotp).not.toHaveBeenCalled(); expect(autofillService.getShouldAutoCopyTotp).not.toHaveBeenCalled();
expect(totpService.getCode).not.toHaveBeenCalled(); expect(totpService.getCode$).not.toHaveBeenCalled();
expect(autofillResult).toBeNull(); expect(autofillResult).toBeNull();
}); });
@@ -956,12 +956,12 @@ describe("AutofillService", () => {
it("returns a null value if the login does not contain a TOTP value", async () => { it("returns a null value if the login does not contain a TOTP value", async () => {
autofillOptions.cipher.login.totp = undefined; autofillOptions.cipher.login.totp = undefined;
jest.spyOn(autofillService, "getShouldAutoCopyTotp"); jest.spyOn(autofillService, "getShouldAutoCopyTotp");
jest.spyOn(totpService, "getCode"); jest.spyOn(totpService, "getCode$");
const autofillResult = await autofillService.doAutoFill(autofillOptions); const autofillResult = await autofillService.doAutoFill(autofillOptions);
expect(autofillService.getShouldAutoCopyTotp).not.toHaveBeenCalled(); expect(autofillService.getShouldAutoCopyTotp).not.toHaveBeenCalled();
expect(totpService.getCode).not.toHaveBeenCalled(); expect(totpService.getCode$).not.toHaveBeenCalled();
expect(autofillResult).toBeNull(); expect(autofillResult).toBeNull();
}); });
@@ -984,12 +984,12 @@ describe("AutofillService", () => {
.spyOn(billingAccountProfileStateService, "hasPremiumFromAnySource$") .spyOn(billingAccountProfileStateService, "hasPremiumFromAnySource$")
.mockImplementation(() => of(true)); .mockImplementation(() => of(true));
jest.spyOn(autofillService, "getShouldAutoCopyTotp").mockResolvedValue(false); jest.spyOn(autofillService, "getShouldAutoCopyTotp").mockResolvedValue(false);
jest.spyOn(totpService, "getCode"); jest.spyOn(totpService, "getCode$");
const autofillResult = await autofillService.doAutoFill(autofillOptions); const autofillResult = await autofillService.doAutoFill(autofillOptions);
expect(autofillService.getShouldAutoCopyTotp).toHaveBeenCalled(); expect(autofillService.getShouldAutoCopyTotp).toHaveBeenCalled();
expect(totpService.getCode).not.toHaveBeenCalled(); expect(totpService.getCode$).not.toHaveBeenCalled();
expect(autofillResult).toBeNull(); expect(autofillResult).toBeNull();
}); });
}); });

View File

@@ -494,7 +494,7 @@ export default class AutofillService implements AutofillServiceInterface {
const shouldAutoCopyTotp = await this.getShouldAutoCopyTotp(); const shouldAutoCopyTotp = await this.getShouldAutoCopyTotp();
totp = shouldAutoCopyTotp totp = shouldAutoCopyTotp
? await this.totpService.getCode(options.cipher.login.totp) ? (await firstValueFrom(this.totpService.getCode$(options.cipher.login.totp))).code
: null; : null;
}), }),
); );
@@ -992,7 +992,10 @@ export default class AutofillService implements AutofillServiceInterface {
} }
filledFields[t.opid] = t; 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) { if (totpValue.length == totps.length) {
totpValue = totpValue.charAt(i); totpValue = totpValue.charAt(i);
} }

View File

@@ -978,7 +978,7 @@ export default class MainBackground {
this.authService, this.authService,
this.accountService, this.accountService,
); );
this.totpService = new TotpService(this.cryptoFunctionService, this.logService); this.totpService = new TotpService(this.sdkService);
this.scriptInjectorService = new BrowserScriptInjectorService( this.scriptInjectorService = new BrowserScriptInjectorService(
this.domainSettingsService, this.domainSettingsService,

View File

@@ -84,6 +84,7 @@ import { MessagingService as MessagingServiceAbstraction } from "@bitwarden/comm
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SdkClientFactory } from "@bitwarden/common/platform/abstractions/sdk/sdk-client-factory"; import { SdkClientFactory } from "@bitwarden/common/platform/abstractions/sdk/sdk-client-factory";
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service"; 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 { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { import {
AbstractStorageService, AbstractStorageService,
@@ -285,7 +286,7 @@ const safeProviders: SafeProvider[] = [
safeProvider({ safeProvider({
provide: TotpServiceAbstraction, provide: TotpServiceAbstraction,
useClass: TotpService, useClass: TotpService,
deps: [CryptoFunctionService, LogService], deps: [SdkService],
}), }),
safeProvider({ safeProvider({
provide: OffscreenDocumentService, provide: OffscreenDocumentService,

View File

@@ -254,8 +254,8 @@ export class GetCommand extends DownloadCommand {
return Response.error("No TOTP available for this login."); return Response.error("No TOTP available for this login.");
} }
const totp = await this.totpService.getCode(cipher.login.totp); const totpResponse = await firstValueFrom(this.totpService.getCode$(cipher.login.totp));
if (totp == null) { if (!totpResponse.code) {
return Response.error("Couldn't generate TOTP 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); return Response.success(res);
} }

View File

@@ -766,7 +766,7 @@ export class ServiceContainer {
this.stateProvider, this.stateProvider,
); );
this.totpService = new TotpService(this.cryptoFunctionService, this.logService); this.totpService = new TotpService(this.sdkService);
this.importApiService = new ImportApiService(this.apiService); this.importApiService = new ImportApiService(this.apiService);

View File

@@ -208,8 +208,8 @@ export class VaultComponent implements OnInit, OnDestroy {
tCipher.login.hasTotp && tCipher.login.hasTotp &&
this.userHasPremiumAccess this.userHasPremiumAccess
) { ) {
const value = await this.totpService.getCode(tCipher.login.totp); const value = await firstValueFrom(this.totpService.getCode$(tCipher.login.totp));
this.copyValue(tCipher, value, "verificationCodeTotp", "TOTP"); this.copyValue(tCipher, value.code, "verificationCodeTotp", "TOTP");
} }
break; break;
} }
@@ -382,8 +382,8 @@ export class VaultComponent implements OnInit, OnDestroy {
menu.push({ menu.push({
label: this.i18nService.t("copyVerificationCodeTotp"), label: this.i18nService.t("copyVerificationCodeTotp"),
click: async () => { click: async () => {
const value = await this.totpService.getCode(cipher.login.totp); const value = await firstValueFrom(this.totpService.getCode$(cipher.login.totp));
this.copyValue(cipher, value, "verificationCodeTotp", "TOTP"); this.copyValue(cipher, value.code, "verificationCodeTotp", "TOTP");
}, },
}); });
} }

View File

@@ -128,22 +128,23 @@
<span class="row-label">{{ "typePasskey" | i18n }}</span> <span class="row-label">{{ "typePasskey" | i18n }}</span>
{{ fido2CredentialCreationDateValue }} {{ fido2CredentialCreationDateValue }}
</div> </div>
<ng-container *ngIf="cipher.login.totp && totpInfo$ | async as totpInfo">
<div <div
class="box-content-row box-content-row-flex totp" class="box-content-row box-content-row-flex totp"
[ngClass]="{ low: totpLow }" [ngClass]="{ low: totpInfo.totpLow }"
*ngIf="cipher.login.totp && totpCode"
> >
<div class="row-main"> <div class="row-main">
<span <span
class="row-label draggable" class="row-label draggable"
draggable="true" draggable="true"
(dragstart)="setTextDataOnDrag($event, totpCode)" (dragstart)="setTextDataOnDrag($event, totpInfo.totpCode)"
>{{ "verificationCodeTotp" | i18n }}</span >{{ "verificationCodeTotp" | i18n }}</span
> >
<span class="totp-code">{{ totpCodeFormatted }}</span> <span class="totp-code">{{ totpInfo.totpCodeFormatted }}</span>
</div> </div>
<span class="totp-countdown" aria-hidden="true"> <span class="totp-countdown" aria-hidden="true">
<span class="totp-sec">{{ totpSec }}</span> <span class="totp-sec">{{ totpInfo.totpSec }}</span>
<svg> <svg>
<g> <g>
<circle <circle
@@ -151,7 +152,7 @@
r="12.6" r="12.6"
cy="16" cy="16"
cx="16" cx="16"
[ngStyle]="{ 'stroke-dashoffset.px': totpDash }" [ngStyle]="{ 'stroke-dashoffset.px': totpInfo.totpDash }"
></circle> ></circle>
<circle class="totp-circle outer" r="14" cy="16" cx="16"></circle> <circle class="totp-circle outer" r="14" cy="16" cx="16"></circle>
</g> </g>
@@ -163,7 +164,7 @@
class="row-btn" class="row-btn"
appStopClick appStopClick
title="{{ 'copyValue' | i18n }}" title="{{ 'copyValue' | i18n }}"
(click)="copy(totpCode, 'verificationCodeTotp', 'TOTP')" (click)="copy(totpInfo.totpCode, 'verificationCodeTotp', 'TOTP')"
> >
<i class="bwi bwi-lg bwi-clone" aria-hidden="true"></i> <i class="bwi bwi-lg bwi-clone" aria-hidden="true"></i>
<span class="sr-only">{{ "copyValue" | i18n }}</span> <span class="sr-only">{{ "copyValue" | i18n }}</span>
@@ -171,11 +172,13 @@
class="sr-only exists-only-on-parent-focus" class="sr-only exists-only-on-parent-focus"
aria-live="polite" aria-live="polite"
aria-atomic="true" aria-atomic="true"
>{{ totpSec }}</span >{{ totpInfo.totpSec }}</span
> >
</button> </button>
</div> </div>
</div> </div>
</ng-container>
<div class="box-content-row box-content-row-flex totp" *ngIf="showPremiumRequiredTotp"> <div class="box-content-row box-content-row-flex totp" *ngIf="showPremiumRequiredTotp">
<div class="row-main"> <div class="row-main">
<span class="row-label">{{ "verificationCodeTotp" | i18n }}</span> <span class="row-label">{{ "verificationCodeTotp" | i18n }}</span>

View File

@@ -1177,7 +1177,8 @@ export class VaultComponent implements OnInit, OnDestroy {
typeI18nKey = "password"; typeI18nKey = "password";
} else if (field === "totp") { } else if (field === "totp") {
aType = "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"; typeI18nKey = "verificationCodeTotp";
} else { } else {
this.toastService.showToast({ this.toastService.showToast({

View File

@@ -135,13 +135,16 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On
if (this.showTotp()) { if (this.showTotp()) {
await this.totpUpdateCode(); await this.totpUpdateCode();
const interval = this.totpService.getTimeInterval(this.cipher.login.totp); const totpResponse = await firstValueFrom(this.totpService.getCode$(this.cipher.login.totp));
if (totpResponse) {
const interval = totpResponse.period;
await this.totpTick(interval); await this.totpTick(interval);
this.totpInterval = window.setInterval(async () => { this.totpInterval = window.setInterval(async () => {
await this.totpTick(interval); await this.totpTick(interval);
}, 1000); }, 1000);
} }
}
this.cardIsExpired = isCardExpired(this.cipher.card); this.cardIsExpired = isCardExpired(this.cipher.card);
} }
@@ -273,7 +276,8 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On
return; 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 != null) {
if (this.totpCode.length > 4) { if (this.totpCode.length > 4) {
const half = Math.floor(this.totpCode.length / 2); const half = Math.floor(this.totpCode.length / 2);

View File

@@ -1146,7 +1146,8 @@ export class VaultComponent implements OnInit, OnDestroy {
typeI18nKey = "password"; typeI18nKey = "password";
} else if (field === "totp") { } else if (field === "totp") {
aType = "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"; typeI18nKey = "verificationCodeTotp";
} else { } else {
this.toastService.showToast({ this.toastService.showToast({

View File

@@ -603,7 +603,7 @@ const safeProviders: SafeProvider[] = [
safeProvider({ safeProvider({
provide: TotpServiceAbstraction, provide: TotpServiceAbstraction,
useClass: TotpService, useClass: TotpService,
deps: [CryptoFunctionServiceAbstraction, LogService], deps: [SdkService],
}), }),
safeProvider({ safeProvider({
provide: TokenServiceAbstraction, provide: TokenServiceAbstraction,

View File

@@ -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 { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; 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 { DialogService, ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management"; import { KeyService } from "@bitwarden/key-management";
import { PasswordRepromptService } from "@bitwarden/vault"; import { PasswordRepromptService } from "@bitwarden/vault";
@@ -66,20 +67,19 @@ export class ViewComponent implements OnDestroy, OnInit {
showPrivateKey: boolean; showPrivateKey: boolean;
canAccessPremium: boolean; canAccessPremium: boolean;
showPremiumRequiredTotp: boolean; showPremiumRequiredTotp: boolean;
totpCode: string;
totpCodeFormatted: string;
totpDash: number;
totpSec: number;
totpLow: boolean;
fieldType = FieldType; fieldType = FieldType;
checkPasswordPromise: Promise<number>; checkPasswordPromise: Promise<number>;
folder: FolderView; folder: FolderView;
cipherType = CipherType; cipherType = CipherType;
private totpInterval: any;
private previousCipherId: string; private previousCipherId: string;
private passwordReprompted = false; private passwordReprompted = false;
/**
* Represents TOTP information including display formatting and timing
*/
protected totpInfo$: Observable<TotpInfo> | undefined;
get fido2CredentialCreationDateValue(): string { get fido2CredentialCreationDateValue(): string {
const dateCreated = this.i18nService.t("dateCreated"); const dateCreated = this.i18nService.t("dateCreated");
const creationDate = this.datePipe.transform( const creationDate = this.datePipe.transform(
@@ -166,19 +166,33 @@ export class ViewComponent implements OnDestroy, OnInit {
).find((f) => f.id == this.cipher.folderId); ).find((f) => f.id == this.cipher.folderId);
} }
if ( const canGenerateTotp =
this.cipher.type === CipherType.Login && this.cipher.type === CipherType.Login &&
this.cipher.login.totp && this.cipher.login.totp &&
(this.cipher.organizationUseTotp || this.canAccessPremium) (this.cipher.organizationUseTotp || this.canAccessPremium);
) {
await this.totpUpdateCode();
const interval = this.totpService.getTimeInterval(this.cipher.login.totp);
await this.totpTick(interval);
this.totpInterval = setInterval(async () => { this.totpInfo$ = canGenerateTotp
await this.totpTick(interval); ? this.totpService.getCode$(this.cipher.login.totp).pipe(
}, 1000); 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) { 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. // 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() { private cleanUp() {
this.totpCode = null;
this.cipher = null; this.cipher = null;
this.folder = null; this.folder = null;
this.showPassword = false; this.showPassword = false;
this.showCardNumber = false; this.showCardNumber = false;
this.showCardCode = false; this.showCardCode = false;
this.passwordReprompted = 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();
}
} }
} }

View File

@@ -1,6 +1,15 @@
// FIXME: Update this file to be type safe and remove this and next line import { Observable } from "rxjs";
// @ts-strict-ignore
import { TotpResponse } from "@bitwarden/sdk-internal";
export abstract class TotpService { export abstract class TotpService {
getCode: (key: string) => Promise<string>; /**
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<TotpResponse>;
} }

View File

@@ -1,17 +1,39 @@
import { mock } from "jest-mock-extended"; import { mock } from "jest-mock-extended";
import { of, take } from "rxjs";
import { LogService } from "../../platform/abstractions/log.service"; import { BitwardenClient, TotpResponse } from "@bitwarden/sdk-internal";
import { WebCryptoFunctionService } from "../../platform/services/web-crypto-function.service";
import { SdkService } from "../../platform/abstractions/sdk/sdk.service";
import { TotpService } from "./totp.service"; import { TotpService } from "./totp.service";
describe("TotpService", () => { describe("TotpService", () => {
let totpService: TotpService; let totpService: TotpService;
let generateTotpMock: jest.Mock;
const logService = mock<LogService>(); const sdkService = mock<SdkService>();
beforeEach(() => { 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 // TOTP is time-based, so we need to mock the current time
jest.useFakeTimers({ jest.useFakeTimers({
@@ -24,40 +46,50 @@ describe("TotpService", () => {
jest.useRealTimers(); jest.useRealTimers();
}); });
it("should return null if key is null", async () => { describe("getCode$", () => {
const result = await totpService.getCode(null); it("should emit TOTP response when key is provided", (done) => {
expect(result).toBeNull(); 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 () => { jest.advanceTimersByTime(1000);
const result = await totpService.getCode("WQIQ25BRKZYCJVYP");
expect(result).toBe("194506");
}); });
it("should handle otpauth keys", async () => { it("should emit TOTP response every second", () => {
const key = "otpauth://totp/test-account?secret=WQIQ25BRKZYCJVYP"; const responses: TotpResponse[] = [];
const result = await totpService.getCode(key);
expect(result).toBe("194506");
const period = totpService.getTimeInterval(key); totpService
expect(period).toBe(30); .getCode$("WQIQ25BRKZYCJVYP")
.pipe(take(3))
.subscribe((result) => {
responses.push(result);
}); });
it("should handle otpauth different period", async () => { jest.advanceTimersByTime(2000);
const key = "otpauth://totp/test-account?secret=WQIQ25BRKZYCJVYP&period=60";
const result = await totpService.getCode(key);
expect(result).toBe("730364");
const period = totpService.getTimeInterval(key); expect(responses).toEqual([
expect(period).toBe(60); { code: "123456", period: 30 },
{ code: "654321", period: 30 },
{ code: "567892", period: 30 },
]);
}); });
it("should handle steam keys", async () => { it("should stop emitting TOTP response after unsubscribing", () => {
const key = "steam://HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ"; const responses: TotpResponse[] = [];
const result = await totpService.getCode(key);
expect(result).toBe("7W6CJ");
const period = totpService.getTimeInterval(key); const subscription = totpService.getCode$("WQIQ25BRKZYCJVYP").subscribe((result) => {
expect(period).toBe(30); responses.push(result);
});
jest.advanceTimersByTime(1000);
subscription.unsubscribe();
jest.advanceTimersByTime(1000);
expect(responses).toHaveLength(2);
});
}); });
}); });

View File

@@ -1,170 +1,43 @@
// FIXME: Update this file to be type safe and remove this and next line import { Observable, map, shareReplay, switchMap, timer } from "rxjs";
// @ts-strict-ignore
import { CryptoFunctionService } from "../../platform/abstractions/crypto-function.service"; import { TotpResponse } from "@bitwarden/sdk-internal";
import { LogService } from "../../platform/abstractions/log.service";
import { Utils } from "../../platform/misc/utils"; import { SdkService } from "../../platform/abstractions/sdk/sdk.service";
import { TotpService as TotpServiceAbstraction } from "../abstractions/totp.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 { export class TotpService implements TotpServiceAbstraction {
constructor( constructor(private sdkService: SdkService) {}
private cryptoFunctionService: CryptoFunctionService,
private logService: LogService,
) {}
async getCode(key: string): Promise<string> { getCode$(key: string): Observable<TotpResponse> {
if (key == null) { return timer(0, 1000).pipe(
return null; switchMap(() =>
} this.sdkService.client$.pipe(
let period = 30; map((sdk) => {
let alg: "sha1" | "sha256" | "sha512" = "sha1"; return sdk.vault().totp().generate_totp(key);
let digits = 6; }),
let keyB32 = key; ),
const isOtpAuth = key.toLowerCase().indexOf("otpauth://") === 0; ),
const isSteamAuth = !isOtpAuth && key.toLowerCase().indexOf("steam://") === 0; shareReplay({ refCount: true, bufferSize: 1 }),
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);
} }
} }

View File

@@ -1,26 +1,33 @@
<ng-container *ngIf="totpInfo$ | async as totpInfo">
<div class="tw-flex tw-items-center tw-justify-center totp-v2"> <div class="tw-flex tw-items-center tw-justify-center totp-v2">
<span class="tw-relative tw-flex tw-justify-center tw-items-center" aria-hidden="true"> <span class="tw-relative tw-flex tw-justify-center tw-items-center" aria-hidden="true">
<span <span
class="tw-absolute" class="tw-absolute"
[ngClass]="{ 'tw-text-main': !totpLow, 'tw-text-danger': totpLow }" [ngClass]="{ 'tw-text-main': !totpInfo.totpLow, 'tw-text-danger': totpInfo.totpLow }"
bitTypography="helper" bitTypography="helper"
>{{ totpSec }}</span >{{ totpInfo.totpSec }}</span
> >
<svg class="tw-size-7" transform="rotate(-90)"> <svg class="tw-size-7" transform="rotate(-90)">
<g> <g>
<circle <circle
class="tw-fill-none" class="tw-fill-none"
[ngClass]="{ 'tw-stroke-text-main': !totpLow, 'tw-stroke-danger-600': totpLow }" [ngClass]="{
'tw-stroke-text-main': !totpInfo.totpLow,
'tw-stroke-danger-600': totpInfo.totpLow,
}"
r="9.5" r="9.5"
cy="14" cy="14"
cx="14" cx="14"
stroke-width="2" stroke-width="2"
stroke-dasharray="60" stroke-dasharray="60"
[ngStyle]="{ 'stroke-dashoffset.px': totpDash }" [ngStyle]="{ 'stroke-dashoffset.px': totpInfo.totpDash }"
></circle> ></circle>
<circle <circle
class="tw-fill-none" class="tw-fill-none"
[ngClass]="{ 'tw-stroke-text-main': !totpLow, 'tw-stroke-danger-600': totpLow }" [ngClass]="{
'tw-stroke-text-main': !totpInfo.totpLow,
'tw-stroke-danger-600': totpInfo.totpLow,
}"
r="11" r="11"
cy="14" cy="14"
cx="14" cx="14"
@@ -32,3 +39,4 @@
</svg> </svg>
</span> </span>
</div> </div>
</ng-container>

View File

@@ -2,9 +2,11 @@
// @ts-strict-ignore // @ts-strict-ignore
import { CommonModule } from "@angular/common"; import { CommonModule } from "@angular/common";
import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core"; 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 { TotpService } from "@bitwarden/common/vault/abstractions/totp.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { TotpInfo } from "@bitwarden/common/vault/services/totp.service";
import { TypographyModule } from "@bitwarden/components"; import { TypographyModule } from "@bitwarden/components";
@Component({ @Component({
@@ -17,69 +19,45 @@ export class BitTotpCountdownComponent implements OnInit {
@Input() cipher: CipherView; @Input() cipher: CipherView;
@Output() sendCopyCode = new EventEmitter(); @Output() sendCopyCode = new EventEmitter();
totpCode: string; /**
totpCodeFormatted: string; * Represents TOTP information including display formatting and timing
totpDash: number; */
totpSec: number; totpInfo$: Observable<TotpInfo> | undefined;
totpLow: boolean;
private totpInterval: any;
constructor(protected totpService: TotpService) {} constructor(protected totpService: TotpService) {}
async ngOnInit() { async ngOnInit() {
await this.totpUpdateCode(); this.totpInfo$ = this.cipher?.login?.totp
const interval = this.totpService.getTimeInterval(this.cipher.login.totp); ? this.totpService.getCode$(this.cipher.login.totp).pipe(
await this.totpTick(interval); map((response) => {
this.totpInterval = setInterval(async () => {
await this.totpTick(interval);
}, 1000);
}
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 epoch = Math.round(new Date().getTime() / 1000.0);
const mod = epoch % intervalSeconds; const mod = epoch % response.period;
this.totpSec = intervalSeconds - mod; return {
this.totpDash = +(Math.round(((60 / intervalSeconds) * mod + "e+2") as any) + "e-2"); totpCode: response.code,
this.totpLow = this.totpSec <= 7; totpCodeFormatted: this.formatTotpCode(response.code),
if (mod === 0) { totpSec: response.period - mod,
await this.totpUpdateCode(); 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 formatTotpCode(): string { private formatTotpCode(code: string): string {
const half = Math.floor(this.totpCode.length / 2); if (code.length > 4) {
return this.totpCode.substring(0, half) + " " + this.totpCode.substring(half); const half = Math.floor(code.length / 2);
} return code.substring(0, half) + " " + code.substring(half);
private clearTotp() {
if (this.totpInterval) {
clearInterval(this.totpInterval);
} }
return code;
} }
} }

View File

@@ -136,10 +136,10 @@ describe("CopyCipherFieldService", () => {
it("should get TOTP code when allowed from premium", async () => { it("should get TOTP code when allowed from premium", async () => {
billingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(true)); 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); const result = await service.copy(valueToCopy, actionType, cipher, skipReprompt);
expect(result).toBeTruthy(); expect(result).toBeTruthy();
expect(totpService.getCode).toHaveBeenCalledWith(valueToCopy); expect(totpService.getCode$).toHaveBeenCalledWith(valueToCopy);
expect(platformUtilsService.copyToClipboard).toHaveBeenCalledWith("123456"); expect(platformUtilsService.copyToClipboard).toHaveBeenCalledWith("123456");
expect(billingAccountProfileStateService.hasPremiumFromAnySource$).toHaveBeenCalledWith( expect(billingAccountProfileStateService.hasPremiumFromAnySource$).toHaveBeenCalledWith(
userId, userId,
@@ -148,10 +148,10 @@ describe("CopyCipherFieldService", () => {
it("should get TOTP code when allowed from organization", async () => { it("should get TOTP code when allowed from organization", async () => {
cipher.organizationUseTotp = true; cipher.organizationUseTotp = true;
totpService.getCode.mockResolvedValue("123456"); totpService.getCode$.mockReturnValue(of({ code: "123456", period: 30 }));
const result = await service.copy(valueToCopy, actionType, cipher, skipReprompt); const result = await service.copy(valueToCopy, actionType, cipher, skipReprompt);
expect(result).toBeTruthy(); expect(result).toBeTruthy();
expect(totpService.getCode).toHaveBeenCalledWith(valueToCopy); expect(totpService.getCode$).toHaveBeenCalledWith(valueToCopy);
expect(platformUtilsService.copyToClipboard).toHaveBeenCalledWith("123456"); expect(platformUtilsService.copyToClipboard).toHaveBeenCalledWith("123456");
}); });
@@ -159,7 +159,7 @@ describe("CopyCipherFieldService", () => {
billingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(false)); billingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(false));
const result = await service.copy(valueToCopy, actionType, cipher, skipReprompt); const result = await service.copy(valueToCopy, actionType, cipher, skipReprompt);
expect(result).toBeFalsy(); expect(result).toBeFalsy();
expect(totpService.getCode).not.toHaveBeenCalled(); expect(totpService.getCode$).not.toHaveBeenCalled();
expect(platformUtilsService.copyToClipboard).not.toHaveBeenCalled(); expect(platformUtilsService.copyToClipboard).not.toHaveBeenCalled();
expect(billingAccountProfileStateService.hasPremiumFromAnySource$).toHaveBeenCalledWith( expect(billingAccountProfileStateService.hasPremiumFromAnySource$).toHaveBeenCalledWith(
userId, userId,
@@ -170,7 +170,7 @@ describe("CopyCipherFieldService", () => {
cipher.login.totp = null; cipher.login.totp = null;
const result = await service.copy(valueToCopy, actionType, cipher, skipReprompt); const result = await service.copy(valueToCopy, actionType, cipher, skipReprompt);
expect(result).toBeFalsy(); expect(result).toBeFalsy();
expect(totpService.getCode).not.toHaveBeenCalled(); expect(totpService.getCode$).not.toHaveBeenCalled();
expect(platformUtilsService.copyToClipboard).not.toHaveBeenCalled(); expect(platformUtilsService.copyToClipboard).not.toHaveBeenCalled();
}); });
}); });

View File

@@ -124,7 +124,11 @@ export class CopyCipherFieldService {
if (!(await this.totpAllowed(cipher))) { if (!(await this.totpAllowed(cipher))) {
return false; 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); this.platformUtilsService.copyToClipboard(valueToCopy);