1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-15 15:53:27 +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

@@ -1,6 +1,15 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Observable } from "rxjs";
import { TotpResponse } from "@bitwarden/sdk-internal";
export abstract class TotpService {
getCode: (key: string) => Promise<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 { of, take } from "rxjs";
import { LogService } from "../../platform/abstractions/log.service";
import { WebCryptoFunctionService } from "../../platform/services/web-crypto-function.service";
import { BitwardenClient, TotpResponse } from "@bitwarden/sdk-internal";
import { SdkService } from "../../platform/abstractions/sdk/sdk.service";
import { TotpService } from "./totp.service";
describe("TotpService", () => {
let totpService: TotpService;
let generateTotpMock: jest.Mock;
const logService = mock<LogService>();
const sdkService = mock<SdkService>();
beforeEach(() => {
totpService = new TotpService(new WebCryptoFunctionService(global), logService);
generateTotpMock = jest
.fn()
.mockReturnValueOnce({
code: "123456",
period: 30,
})
.mockReturnValueOnce({ code: "654321", period: 30 })
.mockReturnValueOnce({ code: "567892", period: 30 });
const mockBitwardenClient = {
vault: () => ({
totp: () => ({
generate_totp: generateTotpMock,
}),
}),
};
sdkService.client$ = of(mockBitwardenClient as unknown as BitwardenClient);
totpService = new TotpService(sdkService);
// TOTP is time-based, so we need to mock the current time
jest.useFakeTimers({
@@ -24,40 +46,50 @@ describe("TotpService", () => {
jest.useRealTimers();
});
it("should return null if key is null", async () => {
const result = await totpService.getCode(null);
expect(result).toBeNull();
});
describe("getCode$", () => {
it("should emit TOTP response when key is provided", (done) => {
totpService
.getCode$("WQIQ25BRKZYCJVYP")
.pipe(take(1))
.subscribe((result) => {
expect(result).toEqual({ code: "123456", period: 30 });
done();
});
it("should return a code if key is not null", async () => {
const result = await totpService.getCode("WQIQ25BRKZYCJVYP");
expect(result).toBe("194506");
});
jest.advanceTimersByTime(1000);
});
it("should handle otpauth keys", async () => {
const key = "otpauth://totp/test-account?secret=WQIQ25BRKZYCJVYP";
const result = await totpService.getCode(key);
expect(result).toBe("194506");
it("should emit TOTP response every second", () => {
const responses: TotpResponse[] = [];
const period = totpService.getTimeInterval(key);
expect(period).toBe(30);
});
totpService
.getCode$("WQIQ25BRKZYCJVYP")
.pipe(take(3))
.subscribe((result) => {
responses.push(result);
});
it("should handle otpauth different period", async () => {
const key = "otpauth://totp/test-account?secret=WQIQ25BRKZYCJVYP&period=60";
const result = await totpService.getCode(key);
expect(result).toBe("730364");
jest.advanceTimersByTime(2000);
const period = totpService.getTimeInterval(key);
expect(period).toBe(60);
});
expect(responses).toEqual([
{ code: "123456", period: 30 },
{ code: "654321", period: 30 },
{ code: "567892", period: 30 },
]);
});
it("should handle steam keys", async () => {
const key = "steam://HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ";
const result = await totpService.getCode(key);
expect(result).toBe("7W6CJ");
it("should stop emitting TOTP response after unsubscribing", () => {
const responses: TotpResponse[] = [];
const period = totpService.getTimeInterval(key);
expect(period).toBe(30);
const subscription = totpService.getCode$("WQIQ25BRKZYCJVYP").subscribe((result) => {
responses.push(result);
});
jest.advanceTimersByTime(1000);
subscription.unsubscribe();
jest.advanceTimersByTime(1000);
expect(responses).toHaveLength(2);
});
});
});

View File

@@ -1,170 +1,43 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { CryptoFunctionService } from "../../platform/abstractions/crypto-function.service";
import { LogService } from "../../platform/abstractions/log.service";
import { Utils } from "../../platform/misc/utils";
import { Observable, map, shareReplay, switchMap, timer } from "rxjs";
import { TotpResponse } from "@bitwarden/sdk-internal";
import { SdkService } from "../../platform/abstractions/sdk/sdk.service";
import { TotpService as TotpServiceAbstraction } from "../abstractions/totp.service";
const B32Chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
const SteamChars = "23456789BCDFGHJKMNPQRTVWXY";
/**
* Represents TOTP information including display formatting and timing
*/
export type TotpInfo = {
/** The TOTP code value */
totpCode: string;
/** The TOTP code value formatted for display, includes spaces */
totpCodeFormatted: string;
/** Progress bar percentage value */
totpDash: number;
/** Seconds remaining until the TOTP code changes */
totpSec: number;
/** Indicates when the code is close to expiring */
totpLow: boolean;
};
export class TotpService implements TotpServiceAbstraction {
constructor(
private cryptoFunctionService: CryptoFunctionService,
private logService: LogService,
) {}
constructor(private sdkService: SdkService) {}
async getCode(key: string): Promise<string> {
if (key == null) {
return null;
}
let period = 30;
let alg: "sha1" | "sha256" | "sha512" = "sha1";
let digits = 6;
let keyB32 = key;
const isOtpAuth = key.toLowerCase().indexOf("otpauth://") === 0;
const isSteamAuth = !isOtpAuth && key.toLowerCase().indexOf("steam://") === 0;
if (isOtpAuth) {
const params = Utils.getQueryParams(key);
if (params.has("digits") && params.get("digits") != null) {
try {
const digitParams = parseInt(params.get("digits").trim(), null);
if (digitParams > 10) {
digits = 10;
} else if (digitParams > 0) {
digits = digitParams;
}
} catch {
this.logService.error("Invalid digits param.");
}
}
if (params.has("period") && params.get("period") != null) {
try {
const periodParam = parseInt(params.get("period").trim(), null);
if (periodParam > 0) {
period = periodParam;
}
} catch {
this.logService.error("Invalid period param.");
}
}
if (params.has("secret") && params.get("secret") != null) {
keyB32 = params.get("secret");
}
if (params.has("algorithm") && params.get("algorithm") != null) {
const algParam = params.get("algorithm").toLowerCase();
if (algParam === "sha1" || algParam === "sha256" || algParam === "sha512") {
alg = algParam;
}
}
} else if (isSteamAuth) {
keyB32 = key.substr("steam://".length);
digits = 5;
}
const epoch = Math.round(new Date().getTime() / 1000.0);
const timeHex = this.leftPad(this.decToHex(Math.floor(epoch / period)), 16, "0");
const timeBytes = Utils.fromHexToArray(timeHex);
const keyBytes = this.b32ToBytes(keyB32);
if (!keyBytes.length || !timeBytes.length) {
return null;
}
const hash = await this.sign(keyBytes, timeBytes, alg);
if (hash.length === 0) {
return null;
}
const offset = hash[hash.length - 1] & 0xf;
const binary =
((hash[offset] & 0x7f) << 24) |
((hash[offset + 1] & 0xff) << 16) |
((hash[offset + 2] & 0xff) << 8) |
(hash[offset + 3] & 0xff);
let otp = "";
if (isSteamAuth) {
let fullCode = binary & 0x7fffffff;
for (let i = 0; i < digits; i++) {
otp += SteamChars[fullCode % SteamChars.length];
fullCode = Math.trunc(fullCode / SteamChars.length);
}
} else {
otp = (binary % Math.pow(10, digits)).toString();
otp = this.leftPad(otp, digits, "0");
}
return otp;
}
getTimeInterval(key: string): number {
let period = 30;
if (key != null && key.toLowerCase().indexOf("otpauth://") === 0) {
const params = Utils.getQueryParams(key);
if (params.has("period") && params.get("period") != null) {
try {
period = parseInt(params.get("period").trim(), null);
} catch {
this.logService.error("Invalid period param.");
}
}
}
return period;
}
// Helpers
private leftPad(s: string, l: number, p: string): string {
if (l + 1 >= s.length) {
s = Array(l + 1 - s.length).join(p) + s;
}
return s;
}
private decToHex(d: number): string {
return (d < 15.5 ? "0" : "") + Math.round(d).toString(16);
}
private b32ToHex(s: string): string {
s = s.toUpperCase();
let cleanedInput = "";
for (let i = 0; i < s.length; i++) {
if (B32Chars.indexOf(s[i]) < 0) {
continue;
}
cleanedInput += s[i];
}
s = cleanedInput;
let bits = "";
let hex = "";
for (let i = 0; i < s.length; i++) {
const byteIndex = B32Chars.indexOf(s.charAt(i));
if (byteIndex < 0) {
continue;
}
bits += this.leftPad(byteIndex.toString(2), 5, "0");
}
for (let i = 0; i + 4 <= bits.length; i += 4) {
const chunk = bits.substr(i, 4);
hex = hex + parseInt(chunk, 2).toString(16);
}
return hex;
}
private b32ToBytes(s: string): Uint8Array {
return Utils.fromHexToArray(this.b32ToHex(s));
}
private async sign(
keyBytes: Uint8Array,
timeBytes: Uint8Array,
alg: "sha1" | "sha256" | "sha512",
) {
const signature = await this.cryptoFunctionService.hmac(timeBytes, keyBytes, alg);
return new Uint8Array(signature);
getCode$(key: string): Observable<TotpResponse> {
return timer(0, 1000).pipe(
switchMap(() =>
this.sdkService.client$.pipe(
map((sdk) => {
return sdk.vault().totp().generate_totp(key);
}),
),
),
shareReplay({ refCount: true, bufferSize: 1 }),
);
}
}