mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
feat: enhance TOTP configuration parsing with algorithm, digits, and period options
This commit is contained in:
+59
-15
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user