feat: enhance TOTP configuration parsing with algorithm, digits, and period options

This commit is contained in:
shuaiplus
2026-05-06 22:23:26 +08:00
parent 429b747710
commit 97d2117e15
+59 -15
View File
@@ -224,26 +224,71 @@ function parseSteamSecret(raw: string): string {
}
}
function parseTotpConfig(raw: string): { secret: string; steam: boolean } {
if (!raw) return { secret: '', steam: false };
type TotpHashAlgorithm = 'SHA-1' | 'SHA-256' | 'SHA-512';
interface TotpConfig {
secret: string;
steam: boolean;
algorithm: TotpHashAlgorithm;
digits: number;
period: number;
}
const DEFAULT_TOTP_CONFIG: Omit<TotpConfig, 'secret' | 'steam'> = {
algorithm: 'SHA-1',
digits: 6,
period: 30,
};
function parseTotpPositiveInt(value: string | null, fallback: number, min: number, max: number): number {
if (!value) return fallback;
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed < min || parsed > max) return fallback;
return parsed;
}
function parseTotpHashAlgorithm(value: string | null): TotpHashAlgorithm {
const normalized = (value || '').trim().toUpperCase().replace(/[^A-Z0-9]/g, '');
if (normalized === 'SHA256') return 'SHA-256';
if (normalized === 'SHA512') return 'SHA-512';
return 'SHA-1';
}
function parseTotpConfig(raw: string): TotpConfig {
if (!raw) return { secret: '', steam: false, ...DEFAULT_TOTP_CONFIG };
const s = raw.trim();
if (!s) return { secret: '', steam: false };
if (!s) return { secret: '', steam: false, ...DEFAULT_TOTP_CONFIG };
if (/^steam:\/\//i.test(s)) {
return { secret: parseSteamSecret(s), steam: true };
return {
secret: parseSteamSecret(s),
steam: true,
algorithm: 'SHA-1',
digits: 5,
period: 30,
};
}
if (/^otpauth:\/\//i.test(s)) {
try {
const u = new URL(s);
if (u.hostname.toLowerCase() !== 'totp') {
return { secret: '', steam: false, ...DEFAULT_TOTP_CONFIG };
}
const label = decodeURIComponent((u.pathname || '').replace(/^\/+/, '')).toLowerCase();
const issuer = (u.searchParams.get('issuer') || '').trim().toLowerCase();
const algorithm = (u.searchParams.get('algorithm') || '').trim().toLowerCase();
const steam = issuer === 'steam' || label.startsWith('steam:') || algorithm === 'steam';
return { secret: normalizeTotpSecret(u.searchParams.get('secret') || ''), steam };
return {
secret: normalizeTotpSecret(u.searchParams.get('secret') || ''),
steam,
algorithm: steam ? 'SHA-1' : parseTotpHashAlgorithm(u.searchParams.get('algorithm')),
digits: steam ? 5 : parseTotpPositiveInt(u.searchParams.get('digits'), DEFAULT_TOTP_CONFIG.digits, 1, 10),
period: parseTotpPositiveInt(u.searchParams.get('period'), DEFAULT_TOTP_CONFIG.period, 1, 3600),
};
} catch {
return { secret: '', steam: false };
return { secret: '', steam: false, ...DEFAULT_TOTP_CONFIG };
}
}
return { secret: normalizeTotpSecret(s), steam: false };
return { secret: normalizeTotpSecret(s), steam: false, ...DEFAULT_TOTP_CONFIG };
}
export function extractTotpSecret(raw: string): string {
@@ -269,15 +314,14 @@ function base32ToBytes(input: string): Uint8Array {
return new Uint8Array(out);
}
export async function calcTotpNow(rawSecret: string): Promise<{ code: string; remain: number } | null> {
const { secret, steam } = parseTotpConfig(rawSecret);
export async function calcTotpNow(rawSecret: string, nowMs: number = Date.now()): Promise<{ code: string; remain: number } | null> {
const { secret, steam, algorithm, digits, period } = parseTotpConfig(rawSecret);
if (!secret) return null;
const keyBytes = base32ToBytes(secret);
if (!keyBytes.length) return null;
const step = 30;
const epoch = Math.floor(Date.now() / 1000);
const counter = Math.floor(epoch / step);
const remain = step - (epoch % step);
const epoch = Math.floor(nowMs / 1000);
const counter = Math.floor(epoch / period);
const remain = period - (epoch % period);
const message = new Uint8Array(8);
let c = counter;
@@ -285,11 +329,11 @@ export async function calcTotpNow(rawSecret: string): Promise<{ code: string; re
message[i] = c & 0xff;
c = Math.floor(c / 256);
}
const key = await crypto.subtle.importKey('raw', toBufferSource(keyBytes), { name: 'HMAC', hash: 'SHA-1' }, false, ['sign']);
const key = await crypto.subtle.importKey('raw', toBufferSource(keyBytes), { name: 'HMAC', hash: algorithm }, false, ['sign']);
const hs = new Uint8Array(await crypto.subtle.sign('HMAC', key, toBufferSource(message)));
const offset = hs[hs.length - 1] & 0x0f;
const bin = ((hs[offset] & 0x7f) << 24) | ((hs[offset + 1] & 0xff) << 16) | ((hs[offset + 2] & 0xff) << 8) | (hs[offset + 3] & 0xff);
let code = (bin % 1000000).toString().padStart(6, '0');
let code = (bin % (10 ** digits)).toString().padStart(digits, '0');
if (steam) {
const chars = '23456789BCDFGHJKMNPQRTVWXY';
let value = bin;