diff --git a/webapp/src/lib/crypto.ts b/webapp/src/lib/crypto.ts index 3580b0d..4a60e8c 100644 --- a/webapp/src/lib/crypto.ts +++ b/webapp/src/lib/crypto.ts @@ -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 = { + 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;