diff --git a/webapp/src/components/TotpCodesPage.tsx b/webapp/src/components/TotpCodesPage.tsx index 99a33c4..fc10610 100644 --- a/webapp/src/components/TotpCodesPage.tsx +++ b/webapp/src/components/TotpCodesPage.tsx @@ -18,7 +18,9 @@ const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS; const failedIconHosts = new Set(); function formatTotp(code: string): string { - if (!code || code.length < 6) return code; + if (!code) return code; + if (code.length === 5) return `${code.slice(0, 2)} ${code.slice(2)}`; + if (code.length < 6) return code; return `${code.slice(0, 3)} ${code.slice(3, 6)}`; } diff --git a/webapp/src/components/vault/vault-page-helpers.tsx b/webapp/src/components/vault/vault-page-helpers.tsx index f18adc5..947c32e 100644 --- a/webapp/src/components/vault/vault-page-helpers.tsx +++ b/webapp/src/components/vault/vault-page-helpers.tsx @@ -235,7 +235,9 @@ export function maskSecret(value: string): string { } export function formatTotp(code: string): string { - if (!code || code.length < 6) return code; + if (!code) return code; + if (code.length === 5) return `${code.slice(0, 2)} ${code.slice(2)}`; + if (code.length < 6) return code; return `${code.slice(0, 3)} ${code.slice(3, 6)}`; } diff --git a/webapp/src/lib/crypto.ts b/webapp/src/lib/crypto.ts index d8ba27e..e7ecc3d 100644 --- a/webapp/src/lib/crypto.ts +++ b/webapp/src/lib/crypto.ts @@ -167,19 +167,31 @@ export async function decryptStr(cipherString: string | null | undefined, encKey return new TextDecoder().decode(plain); } -export function extractTotpSecret(raw: string): string { - if (!raw) return ''; +function normalizeTotpSecret(secret: string): string { + return secret.toUpperCase().replace(/[\s-]/g, '').replace(/=+$/g, ''); +} + +function parseTotpConfig(raw: string): { secret: string; steam: boolean } { + if (!raw) return { secret: '', steam: false }; const s = raw.trim(); - if (!s) return ''; + if (!s) return { secret: '', steam: false }; if (/^otpauth:\/\//i.test(s)) { try { const u = new URL(s); - return (u.searchParams.get('secret') || '').toUpperCase().replace(/[\s-]/g, '').replace(/=+$/g, ''); + 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 }; } catch { - return ''; + return { secret: '', steam: false }; } } - return s.toUpperCase().replace(/[\s-]/g, '').replace(/=+$/g, ''); + return { secret: normalizeTotpSecret(s), steam: false }; +} + +export function extractTotpSecret(raw: string): string { + return parseTotpConfig(raw).secret; } function base32ToBytes(input: string): Uint8Array { @@ -202,7 +214,7 @@ function base32ToBytes(input: string): Uint8Array { } export async function calcTotpNow(rawSecret: string): Promise<{ code: string; remain: number } | null> { - const secret = extractTotpSecret(rawSecret); + const { secret, steam } = parseTotpConfig(rawSecret); if (!secret) return null; const keyBytes = base32ToBytes(secret); if (!keyBytes.length) return null; @@ -221,6 +233,15 @@ export async function calcTotpNow(rawSecret: string): Promise<{ code: string; re 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); - const code = (bin % 1000000).toString().padStart(6, '0'); + let code = (bin % 1000000).toString().padStart(6, '0'); + if (steam) { + const chars = '23456789BCDFGHJKMNPQRTVWXY'; + let value = bin; + code = ''; + for (let i = 0; i < 5; i += 1) { + code += chars[value % chars.length]; + value = Math.floor(value / chars.length); + } + } return { code, remain }; }