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:
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,54 +128,57 @@
|
|||||||
<span class="row-label">{{ "typePasskey" | i18n }}</span>
|
<span class="row-label">{{ "typePasskey" | i18n }}</span>
|
||||||
{{ fido2CredentialCreationDateValue }}
|
{{ fido2CredentialCreationDateValue }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
class="box-content-row box-content-row-flex totp"
|
<ng-container *ngIf="cipher.login.totp && totpInfo$ | async as totpInfo">
|
||||||
[ngClass]="{ low: totpLow }"
|
<div
|
||||||
*ngIf="cipher.login.totp && totpCode"
|
class="box-content-row box-content-row-flex totp"
|
||||||
>
|
[ngClass]="{ low: totpInfo.totpLow }"
|
||||||
<div class="row-main">
|
>
|
||||||
<span
|
<div class="row-main">
|
||||||
class="row-label draggable"
|
|
||||||
draggable="true"
|
|
||||||
(dragstart)="setTextDataOnDrag($event, totpCode)"
|
|
||||||
>{{ "verificationCodeTotp" | i18n }}</span
|
|
||||||
>
|
|
||||||
<span class="totp-code">{{ totpCodeFormatted }}</span>
|
|
||||||
</div>
|
|
||||||
<span class="totp-countdown" aria-hidden="true">
|
|
||||||
<span class="totp-sec">{{ totpSec }}</span>
|
|
||||||
<svg>
|
|
||||||
<g>
|
|
||||||
<circle
|
|
||||||
class="totp-circle inner"
|
|
||||||
r="12.6"
|
|
||||||
cy="16"
|
|
||||||
cx="16"
|
|
||||||
[ngStyle]="{ 'stroke-dashoffset.px': totpDash }"
|
|
||||||
></circle>
|
|
||||||
<circle class="totp-circle outer" r="14" cy="16" cx="16"></circle>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
<div class="action-buttons">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="row-btn"
|
|
||||||
appStopClick
|
|
||||||
title="{{ 'copyValue' | i18n }}"
|
|
||||||
(click)="copy(totpCode, 'verificationCodeTotp', 'TOTP')"
|
|
||||||
>
|
|
||||||
<i class="bwi bwi-lg bwi-clone" aria-hidden="true"></i>
|
|
||||||
<span class="sr-only">{{ "copyValue" | i18n }}</span>
|
|
||||||
<span
|
<span
|
||||||
class="sr-only exists-only-on-parent-focus"
|
class="row-label draggable"
|
||||||
aria-live="polite"
|
draggable="true"
|
||||||
aria-atomic="true"
|
(dragstart)="setTextDataOnDrag($event, totpInfo.totpCode)"
|
||||||
>{{ totpSec }}</span
|
>{{ "verificationCodeTotp" | i18n }}</span
|
||||||
>
|
>
|
||||||
</button>
|
<span class="totp-code">{{ totpInfo.totpCodeFormatted }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="totp-countdown" aria-hidden="true">
|
||||||
|
<span class="totp-sec">{{ totpInfo.totpSec }}</span>
|
||||||
|
<svg>
|
||||||
|
<g>
|
||||||
|
<circle
|
||||||
|
class="totp-circle inner"
|
||||||
|
r="12.6"
|
||||||
|
cy="16"
|
||||||
|
cx="16"
|
||||||
|
[ngStyle]="{ 'stroke-dashoffset.px': totpInfo.totpDash }"
|
||||||
|
></circle>
|
||||||
|
<circle class="totp-circle outer" r="14" cy="16" cx="16"></circle>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<div class="action-buttons">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="row-btn"
|
||||||
|
appStopClick
|
||||||
|
title="{{ 'copyValue' | i18n }}"
|
||||||
|
(click)="copy(totpInfo.totpCode, 'verificationCodeTotp', 'TOTP')"
|
||||||
|
>
|
||||||
|
<i class="bwi bwi-lg bwi-clone" aria-hidden="true"></i>
|
||||||
|
<span class="sr-only">{{ "copyValue" | i18n }}</span>
|
||||||
|
<span
|
||||||
|
class="sr-only exists-only-on-parent-focus"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-atomic="true"
|
||||||
|
>{{ totpInfo.totpSec }}</span
|
||||||
|
>
|
||||||
|
</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>
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -135,12 +135,15 @@ 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));
|
||||||
await this.totpTick(interval);
|
if (totpResponse) {
|
||||||
|
const interval = totpResponse.period;
|
||||||
this.totpInterval = window.setInterval(async () => {
|
|
||||||
await this.totpTick(interval);
|
await this.totpTick(interval);
|
||||||
}, 1000);
|
|
||||||
|
this.totpInterval = window.setInterval(async () => {
|
||||||
|
await this.totpTick(interval);
|
||||||
|
}, 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);
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,34 +1,42 @@
|
|||||||
<div class="tw-flex tw-items-center tw-justify-center totp-v2">
|
<ng-container *ngIf="totpInfo$ | async as totpInfo">
|
||||||
<span class="tw-relative tw-flex tw-justify-center tw-items-center" aria-hidden="true">
|
<div class="tw-flex tw-items-center tw-justify-center totp-v2">
|
||||||
<span
|
<span class="tw-relative tw-flex tw-justify-center tw-items-center" aria-hidden="true">
|
||||||
class="tw-absolute"
|
<span
|
||||||
[ngClass]="{ 'tw-text-main': !totpLow, 'tw-text-danger': totpLow }"
|
class="tw-absolute"
|
||||||
bitTypography="helper"
|
[ngClass]="{ 'tw-text-main': !totpInfo.totpLow, 'tw-text-danger': totpInfo.totpLow }"
|
||||||
>{{ totpSec }}</span
|
bitTypography="helper"
|
||||||
>
|
>{{ totpInfo.totpSec }}</span
|
||||||
<svg class="tw-size-7" transform="rotate(-90)">
|
>
|
||||||
<g>
|
<svg class="tw-size-7" transform="rotate(-90)">
|
||||||
<circle
|
<g>
|
||||||
class="tw-fill-none"
|
<circle
|
||||||
[ngClass]="{ 'tw-stroke-text-main': !totpLow, 'tw-stroke-danger-600': totpLow }"
|
class="tw-fill-none"
|
||||||
r="9.5"
|
[ngClass]="{
|
||||||
cy="14"
|
'tw-stroke-text-main': !totpInfo.totpLow,
|
||||||
cx="14"
|
'tw-stroke-danger-600': totpInfo.totpLow,
|
||||||
stroke-width="2"
|
}"
|
||||||
stroke-dasharray="60"
|
r="9.5"
|
||||||
[ngStyle]="{ 'stroke-dashoffset.px': totpDash }"
|
cy="14"
|
||||||
></circle>
|
cx="14"
|
||||||
<circle
|
stroke-width="2"
|
||||||
class="tw-fill-none"
|
stroke-dasharray="60"
|
||||||
[ngClass]="{ 'tw-stroke-text-main': !totpLow, 'tw-stroke-danger-600': totpLow }"
|
[ngStyle]="{ 'stroke-dashoffset.px': totpInfo.totpDash }"
|
||||||
r="11"
|
></circle>
|
||||||
cy="14"
|
<circle
|
||||||
cx="14"
|
class="tw-fill-none"
|
||||||
stroke-width="1"
|
[ngClass]="{
|
||||||
stroke-dasharray="71"
|
'tw-stroke-text-main': !totpInfo.totpLow,
|
||||||
stroke-dashoffset="0"
|
'tw-stroke-danger-600': totpInfo.totpLow,
|
||||||
></circle>
|
}"
|
||||||
</g>
|
r="11"
|
||||||
</svg>
|
cy="14"
|
||||||
</span>
|
cx="14"
|
||||||
</div>
|
stroke-width="1"
|
||||||
|
stroke-dasharray="71"
|
||||||
|
stroke-dashoffset="0"
|
||||||
|
></circle>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
const epoch = Math.round(new Date().getTime() / 1000.0);
|
||||||
|
const mod = epoch % response.period;
|
||||||
|
|
||||||
this.totpInterval = setInterval(async () => {
|
return {
|
||||||
await this.totpTick(interval);
|
totpCode: response.code,
|
||||||
}, 1000);
|
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() {
|
private formatTotpCode(code: string): string {
|
||||||
if (this.cipher.login.totp == null) {
|
if (code.length > 4) {
|
||||||
this.clearTotp();
|
const half = Math.floor(code.length / 2);
|
||||||
return;
|
return code.substring(0, half) + " " + code.substring(half);
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
return code;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user