diff --git a/webapp/src/components/TotpCodesPage.tsx b/webapp/src/components/TotpCodesPage.tsx index 1bffc9b..83d1ee8 100644 --- a/webapp/src/components/TotpCodesPage.tsx +++ b/webapp/src/components/TotpCodesPage.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; import { Clipboard, Globe } from 'lucide-preact'; import { copyTextToClipboard as copyTextWithFeedback } from '@/lib/clipboard'; -import { calcTotpNow } from '@/lib/crypto'; +import { calcTotpNow, type TotpCodeResult } from '@/lib/crypto'; import { t } from '@/lib/i18n'; import type { Cipher } from '@/lib/types'; import LoadingState from '@/components/LoadingState'; @@ -14,17 +14,9 @@ interface TotpCodesPageProps { onNotify: (type: 'success' | 'error', text: string) => void; } -const TOTP_PERIOD_SECONDS = 30; const TOTP_RING_RADIUS = 14; const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS; const TOTP_REFRESH_BATCH_SIZE = 16; -function getTotpTimeState(): { windowId: number; remain: number } { - const epoch = Math.floor(Date.now() / 1000); - return { - windowId: Math.floor(epoch / TOTP_PERIOD_SECONDS), - remain: TOTP_PERIOD_SECONDS - (epoch % TOTP_PERIOD_SECONDS), - }; -} function TotpListIcon({ cipher }: { cipher: Cipher }) { return } />; @@ -32,13 +24,15 @@ function TotpListIcon({ cipher }: { cipher: Cipher }) { interface TotpRowProps { cipher: Cipher; - live: { code: string; remain: number } | null; + live: TotpCodeResult | null; onCopy: (value: string) => void; } function TotpRow(props: TotpRowProps) { const name = props.cipher.decName || props.cipher.name || t('txt_no_name'); const username = props.cipher.login?.decUsername || ''; + const period = Math.max(1, props.live?.period || 30); + const progress = props.live ? Math.max(0, Math.min(period, props.live.remain)) / period : 0; return (
@@ -69,8 +63,7 @@ function TotpRow(props: TotpRowProps) { strokeDasharray: `${TOTP_RING_CIRCUMFERENCE} ${TOTP_RING_CIRCUMFERENCE}`, strokeDashoffset: String( TOTP_RING_CIRCUMFERENCE - - TOTP_RING_CIRCUMFERENCE * - (Math.max(0, Math.min(TOTP_PERIOD_SECONDS, props.live?.remain ?? 0)) / TOTP_PERIOD_SECONDS) + TOTP_RING_CIRCUMFERENCE * progress ), }} /> @@ -86,8 +79,7 @@ function TotpRow(props: TotpRowProps) { } export default function TotpCodesPage(props: TotpCodesPageProps) { - const [totpCodes, setTotpCodes] = useState>({}); - const [remainingSeconds, setRemainingSeconds] = useState(() => getTotpTimeState().remain); + const [totpCodes, setTotpCodes] = useState>({}); const [columnCount, setColumnCount] = useState(1); const listRef = useRef(null); @@ -120,11 +112,10 @@ export default function TotpCodesPage(props: TotpCodesPageProps) { let stopped = false; let activeRun = 0; let timer = 0; - let currentWindowId = -1; const refreshCodes = async () => { const runId = ++activeRun; - const nextCodes: Record = {}; + const nextCodes: Record = {}; for (let start = 0; start < totpItems.length; start += TOTP_REFRESH_BATCH_SIZE) { if (stopped || runId !== activeRun) return; const batch = totpItems.slice(start, start + TOTP_REFRESH_BATCH_SIZE); @@ -132,7 +123,7 @@ export default function TotpCodesPage(props: TotpCodesPageProps) { batch.map(async (cipher) => { try { const next = await calcTotpNow(cipher.login?.decTotp || ''); - return [cipher.id, next?.code || null] as const; + return [cipher.id, next] as const; } catch { return [cipher.id, null] as const; } @@ -146,15 +137,20 @@ export default function TotpCodesPage(props: TotpCodesPageProps) { if (stopped || runId !== activeRun) return; setTotpCodes((prev) => { let changed = false; - const next: Record = { ...prev }; + const next: Record = { ...prev }; for (const id of Object.keys(next)) { if (id in nextCodes) continue; delete next[id]; changed = true; } - for (const [id, code] of Object.entries(nextCodes)) { - if (next[id] === code) continue; - next[id] = code; + for (const [id, live] of Object.entries(nextCodes)) { + const prevLive = next[id]; + if ( + prevLive?.code === live?.code && + prevLive?.remain === live?.remain && + prevLive?.period === live?.period + ) continue; + next[id] = live; changed = true; } return changed ? next : prev; @@ -162,10 +158,6 @@ export default function TotpCodesPage(props: TotpCodesPageProps) { }; const tick = () => { - const next = getTotpTimeState(); - setRemainingSeconds((prev) => (prev === next.remain ? prev : next.remain)); - if (next.windowId === currentWindowId) return; - currentWindowId = next.windowId; void refreshCodes(); }; @@ -215,7 +207,7 @@ export default function TotpCodesPage(props: TotpCodesPageProps) { void copyToClipboard(value)} /> ))} diff --git a/webapp/src/components/VaultPage.tsx b/webapp/src/components/VaultPage.tsx index a00d07b..cba0ce3 100644 --- a/webapp/src/components/VaultPage.tsx +++ b/webapp/src/components/VaultPage.tsx @@ -31,7 +31,7 @@ import { type SidebarFilter, type VaultSortMode, } from '@/components/vault/vault-page-helpers'; -import { calcTotpNow } from '@/lib/crypto'; +import { calcTotpNow, type TotpCodeResult } from '@/lib/crypto'; import { computeSshFingerprint, generateDefaultSshKeyMaterial } from '@/lib/ssh'; import { ChevronLeft } from 'lucide-preact'; import type { Cipher, CustomFieldType, Folder, VaultDraft, VaultDraftField } from '@/lib/types'; @@ -109,7 +109,7 @@ export default function VaultPage(props: VaultPageProps) { const [renameFolderName, setRenameFolderName] = useState(''); const [pendingDeleteFolder, setPendingDeleteFolder] = useState(null); const [deleteAllFoldersOpen, setDeleteAllFoldersOpen] = useState(false); - const [totpLive, setTotpLive] = useState<{ code: string; remain: number } | null>(null); + const [totpLive, setTotpLive] = useState(null); const [hiddenFieldVisibleMap, setHiddenFieldVisibleMap] = useState>({}); const [attachmentQueue, setAttachmentQueue] = useState([]); const [removedAttachmentIds, setRemovedAttachmentIds] = useState>({}); diff --git a/webapp/src/components/vault/VaultDetailView.tsx b/webapp/src/components/vault/VaultDetailView.tsx index c138f17..3415191 100644 --- a/webapp/src/components/vault/VaultDetailView.tsx +++ b/webapp/src/components/vault/VaultDetailView.tsx @@ -2,11 +2,11 @@ import { createPortal } from 'preact/compat'; import { useEffect, useMemo, useState } from 'preact/hooks'; import { Archive, Clipboard, Download, Eye, EyeOff, ExternalLink, Folder, Paperclip, Pencil, RotateCcw, Trash2, X } from 'lucide-preact'; import { useDialogLifecycle } from '@/components/ConfirmDialog'; +import type { TotpCodeResult } from '@/lib/crypto'; import type { Cipher } from '@/lib/types'; import { t } from '@/lib/i18n'; import { CardBrandIcon, - TOTP_PERIOD_SECONDS, TOTP_RING_CIRCUMFERENCE, VaultListIcon, copyToClipboard, @@ -25,7 +25,7 @@ interface VaultDetailViewProps { selectedCipher: Cipher; repromptApprovedCipherId: string | null; showPassword: boolean; - totpLive: { code: string; remain: number } | null; + totpLive: TotpCodeResult | null; passkeyCreatedAt: string | null; hiddenFieldVisibleMap: Record; folderName: (id: string | null | undefined) => string; @@ -42,6 +42,11 @@ interface VaultDetailViewProps { onUnarchive: (cipher: Cipher) => void | Promise; } +function totpProgress(live: TotpCodeResult | null): number { + const period = Math.max(1, live?.period || 30); + return live ? Math.max(0, Math.min(period, live.remain)) / period : 0; +} + function PasswordHistoryDialog(props: { open: boolean; entries: Array<{ password: string; lastUsedDate: string | null }>; @@ -191,8 +196,7 @@ export default function VaultDetailView(props: VaultDetailViewProps) { strokeDasharray: `${TOTP_RING_CIRCUMFERENCE} ${TOTP_RING_CIRCUMFERENCE}`, strokeDashoffset: String( TOTP_RING_CIRCUMFERENCE - - TOTP_RING_CIRCUMFERENCE * - (Math.max(0, Math.min(TOTP_PERIOD_SECONDS, props.totpLive?.remain ?? 0)) / TOTP_PERIOD_SECONDS) + TOTP_RING_CIRCUMFERENCE * totpProgress(props.totpLive) ), }} /> diff --git a/webapp/src/components/vault/VaultEditor.tsx b/webapp/src/components/vault/VaultEditor.tsx index 7bc6d1f..ef0bb95 100644 --- a/webapp/src/components/vault/VaultEditor.tsx +++ b/webapp/src/components/vault/VaultEditor.tsx @@ -4,6 +4,7 @@ import { ArrowDown, ArrowUp, CheckCheck, Download, Paperclip, Plus, QrCode, Refr import jsQR from 'jsqr'; import { useEffect, useRef, useState } from 'preact/hooks'; import { useDialogLifecycle } from '@/components/ConfirmDialog'; +import { normalizeTotpInput } from '@/lib/crypto'; import type { Cipher, Folder, VaultDraft, VaultDraftField } from '@/lib/types'; import { t } from '@/lib/i18n'; import { cardBrand } from '@/lib/import-format-shared'; @@ -161,9 +162,9 @@ export default function VaultEditor(props: VaultEditorProps) { }; const applyTotpQrValue = (value: string) => { - const trimmed = value.trim(); - if (!trimmed) return false; - props.onUpdateDraft({ loginTotp: trimmed }); + const normalized = normalizeTotpInput(value); + if (!normalized) return false; + props.onUpdateDraft({ loginTotp: normalized }); setTotpQrStatus(t('txt_totp_qr_scanned')); setTotpQrOpen(false); return true; diff --git a/webapp/src/components/vault/vault-page-helpers.tsx b/webapp/src/components/vault/vault-page-helpers.tsx index 79bab46..8288394 100644 --- a/webapp/src/components/vault/vault-page-helpers.tsx +++ b/webapp/src/components/vault/vault-page-helpers.tsx @@ -207,8 +207,7 @@ export function getWebsiteMatchOptions(): Array<{ value: number | null; label: s ]; } -export const TOTP_PERIOD_SECONDS = 30; -export const TOTP_RING_RADIUS = 14; +const TOTP_RING_RADIUS = 14; export const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS; export function CreateTypeIcon({ type }: { type: number }) { diff --git a/webapp/src/lib/crypto.ts b/webapp/src/lib/crypto.ts index af59509..0e8ffa3 100644 --- a/webapp/src/lib/crypto.ts +++ b/webapp/src/lib/crypto.ts @@ -259,17 +259,33 @@ interface TotpConfig { period: number; } +interface GoogleAuthenticatorMigrationTotp { + secret: string; + name: string; + issuer: string; + 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; +function parseTotpDigits(value: string | null): number { + if (!value) return DEFAULT_TOTP_CONFIG.digits; const parsed = Number(value); - if (!Number.isInteger(parsed) || parsed < min || parsed > max) return fallback; - return parsed; + if (!Number.isInteger(parsed)) return DEFAULT_TOTP_CONFIG.digits; + return Math.max(0, Math.min(10, parsed)); +} + +function parseTotpPeriod(value: string | null): number { + if (!value) return DEFAULT_TOTP_CONFIG.period; + const parsed = Number(value); + if (!Number.isSafeInteger(parsed)) return DEFAULT_TOTP_CONFIG.period; + return Math.max(1, parsed); } function parseTotpHashAlgorithm(value: string | null): TotpHashAlgorithm { @@ -279,9 +295,190 @@ function parseTotpHashAlgorithm(value: string | null): TotpHashAlgorithm { return 'SHA-1'; } -function parseTotpConfig(raw: string): TotpConfig { - if (!raw) return { secret: '', steam: false, ...DEFAULT_TOTP_CONFIG }; +function base64ToBytesLoose(value: string): Uint8Array { + const normalized = value.trim().replace(/\s/g, '+').replace(/-/g, '+').replace(/_/g, '/'); + if (!normalized) return new Uint8Array(); + const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4); + try { + const binary = atob(padded); + return Uint8Array.from(binary, (char) => char.charCodeAt(0)); + } catch { + return new Uint8Array(); + } +} + +function bytesToBase32(bytes: Uint8Array): string { + const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + let bits = 0; + let value = 0; + let out = ''; + for (const byte of bytes) { + value = (value << 8) | byte; + bits += 8; + while (bits >= 5) { + out += alphabet[(value >>> (bits - 5)) & 31]; + bits -= 5; + } + } + if (bits > 0) { + out += alphabet[(value << (5 - bits)) & 31]; + } + return out; +} + +function readProtoVarint(bytes: Uint8Array, state: { offset: number }): number | null { + let result = 0; + let factor = 1; + for (let i = 0; i < 10 && state.offset < bytes.length; i += 1) { + const byte = bytes[state.offset++]; + result += (byte & 0x7f) * factor; + if ((byte & 0x80) === 0) return Number.isSafeInteger(result) ? result : null; + factor *= 128; + } + return null; +} + +function readProtoBytes(bytes: Uint8Array, state: { offset: number }): Uint8Array | null { + const length = readProtoVarint(bytes, state); + if (length == null || length < 0 || state.offset + length > bytes.length) return null; + const out = bytes.slice(state.offset, state.offset + length); + state.offset += length; + return out; +} + +function skipProtoField(bytes: Uint8Array, state: { offset: number }, wireType: number): boolean { + if (wireType === 0) return readProtoVarint(bytes, state) != null; + if (wireType === 1 && state.offset + 8 <= bytes.length) { + state.offset += 8; + return true; + } + if (wireType === 2) return readProtoBytes(bytes, state) != null; + if (wireType === 5 && state.offset + 4 <= bytes.length) { + state.offset += 4; + return true; + } + return false; +} + +function googleMigrationAlgorithm(value: number): TotpHashAlgorithm | null { + if (value === 0 || value === 1) return 'SHA-1'; + if (value === 2) return 'SHA-256'; + if (value === 3) return 'SHA-512'; + return null; +} + +function googleMigrationDigits(value: number): number { + if (value === 2) return 8; + return 6; +} + +function parseGoogleMigrationOtpParameter(bytes: Uint8Array): GoogleAuthenticatorMigrationTotp | null { + const state = { offset: 0 }; + let secretBytes: Uint8Array | null = null; + let name = ''; + let issuer = ''; + let algorithm: TotpHashAlgorithm | null = 'SHA-1'; + let digits = 6; + let otpType = 0; + const decoder = new TextDecoder(); + + while (state.offset < bytes.length) { + const key = readProtoVarint(bytes, state); + if (key == null) return null; + const fieldNumber = Math.floor(key / 8); + const wireType = key % 8; + + if (fieldNumber === 1 && wireType === 2) { + secretBytes = readProtoBytes(bytes, state); + } else if (fieldNumber === 2 && wireType === 2) { + const value = readProtoBytes(bytes, state); + name = value ? decoder.decode(value) : ''; + } else if (fieldNumber === 3 && wireType === 2) { + const value = readProtoBytes(bytes, state); + issuer = value ? decoder.decode(value) : ''; + } else if (fieldNumber === 4 && wireType === 0) { + const value = readProtoVarint(bytes, state); + algorithm = value == null ? null : googleMigrationAlgorithm(value); + } else if (fieldNumber === 5 && wireType === 0) { + const value = readProtoVarint(bytes, state); + digits = googleMigrationDigits(value ?? 0); + } else if (fieldNumber === 6 && wireType === 0) { + otpType = readProtoVarint(bytes, state) ?? 0; + } else if (!skipProtoField(bytes, state, wireType)) { + return null; + } + } + + if (!secretBytes?.length || !algorithm || otpType === 1) return null; + return { + secret: bytesToBase32(secretBytes), + name, + issuer, + algorithm, + digits, + period: DEFAULT_TOTP_CONFIG.period, + }; +} + +function parseGoogleAuthenticatorMigration(raw: string): GoogleAuthenticatorMigrationTotp[] { + let data = ''; + try { + data = new URL(raw).searchParams.get('data') || ''; + } catch { + data = readOtpAuthParam(raw, 'data'); + } + const bytes = base64ToBytesLoose(data); + if (!bytes.length) return []; + + const state = { offset: 0 }; + const out: GoogleAuthenticatorMigrationTotp[] = []; + while (state.offset < bytes.length) { + const key = readProtoVarint(bytes, state); + if (key == null) return []; + const fieldNumber = Math.floor(key / 8); + const wireType = key % 8; + if (fieldNumber === 1 && wireType === 2) { + const parameterBytes = readProtoBytes(bytes, state); + const parameter = parameterBytes ? parseGoogleMigrationOtpParameter(parameterBytes) : null; + if (parameter) out.push(parameter); + } else if (!skipProtoField(bytes, state, wireType)) { + return []; + } + } + return out; +} + +function buildOtpAuthUri(account: GoogleAuthenticatorMigrationTotp): string { + const issuer = account.issuer.trim(); + const name = account.name.trim(); + const label = issuer && name && !name.toLowerCase().startsWith(`${issuer.toLowerCase()}:`) + ? `${issuer}:${name}` + : name || issuer || 'TOTP'; + const params = new URLSearchParams({ + secret: account.secret, + algorithm: account.algorithm.replace('-', ''), + digits: String(account.digits), + period: String(account.period), + }); + if (issuer) params.set('issuer', issuer); + return `otpauth://totp/${encodeURIComponent(label)}?${params.toString()}`; +} + +export function normalizeTotpInput(raw: string): string { const s = raw.trim(); + if (!s) return ''; + if (/^otpauth-migration:\/\//i.test(s)) { + const accounts = parseGoogleAuthenticatorMigration(s); + return accounts.length === 1 ? buildOtpAuthUri(accounts[0]) : ''; + } + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(s) && !/^otpauth:\/\//i.test(s) && !/^steam:\/\//i.test(s)) { + return ''; + } + return s; +} + +function parseTotpConfig(raw: string): TotpConfig { + const s = normalizeTotpInput(raw); if (!s) return { secret: '', steam: false, ...DEFAULT_TOTP_CONFIG }; if (/^steam:\/\//i.test(s)) { return { @@ -295,31 +492,20 @@ function parseTotpConfig(raw: string): TotpConfig { if (/^otpauth:\/\//i.test(s)) { try { const u = new URL(s); - const otpType = u.hostname.toLowerCase(); - if (otpType !== '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, - 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), + steam: false, + algorithm: parseTotpHashAlgorithm(u.searchParams.get('algorithm')), + digits: parseTotpDigits(u.searchParams.get('digits')), + period: parseTotpPeriod(u.searchParams.get('period')), }; } catch { - const issuer = readOtpAuthParam(s, 'issuer').trim().toLowerCase(); - const algorithm = readOtpAuthParam(s, 'algorithm').trim().toLowerCase(); - const steam = issuer === 'steam' || algorithm === 'steam'; return { secret: normalizeTotpSecret(readOtpAuthParam(s, 'secret')), - steam, - algorithm: steam ? 'SHA-1' : parseTotpHashAlgorithm(algorithm), - digits: steam ? 5 : parseTotpPositiveInt(readOtpAuthParam(s, 'digits'), DEFAULT_TOTP_CONFIG.digits, 1, 10), - period: parseTotpPositiveInt(readOtpAuthParam(s, 'period'), DEFAULT_TOTP_CONFIG.period, 1, 3600), + steam: false, + algorithm: parseTotpHashAlgorithm(readOtpAuthParam(s, 'algorithm')), + digits: parseTotpDigits(readOtpAuthParam(s, 'digits')), + period: parseTotpPeriod(readOtpAuthParam(s, 'period')), }; } } @@ -349,7 +535,13 @@ function base32ToBytes(input: string): Uint8Array { return new Uint8Array(out); } -export async function calcTotpNow(rawSecret: string, nowMs: number = Date.now()): Promise<{ code: string; remain: number } | null> { +export interface TotpCodeResult { + code: string; + remain: number; + period: number; +} + +export async function calcTotpNow(rawSecret: string, nowMs: number = Date.now()): Promise { const { secret, steam, algorithm, digits, period } = parseTotpConfig(rawSecret); if (!secret) return null; const keyBytes = base32ToBytes(secret); @@ -378,5 +570,5 @@ export async function calcTotpNow(rawSecret: string, nowMs: number = Date.now()) value = Math.floor(value / chars.length); } } - return { code, remain }; + return { code, remain, period }; }