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:
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user