diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 09c9db6..8e3f98e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,4 +1,4 @@ -# 自测 CI:无头 Chrome 打开 tests.html——它会从 index.html 提取真实实现执行 98 项自测。 +# 自测 CI:无头 Chrome 打开 tests.html——它会从 index.html 提取真实实现执行核心逻辑与回归自测。 # 测的就是仓库里那份 index.html,与手动开 tests.html 完全同一条路径;无 npm、无额外依赖。 name: self-tests diff --git a/README.md b/README.md index 4f9f8f6..b0ab37f 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ [](LICENSE) [](#deployment) [](#how-it-works) -[](tests.html) +[](tests.html) [](https://github.com/zeropl/2FA/actions/workflows/tests.yml) English · [简体中文](README.zh-CN.md) @@ -69,7 +69,8 @@ Anyone can claim "we don't upload your data". Easy2FA makes **the browser verify For a 2FA tool, **calmly displaying a wrong code is worse than crashing** — you'll retry it until the account locks you out. So: -- The algorithm is pinned by the official RFC 6238 test vectors, and the **98 self-tests in [`tests.html`](tests.html) execute the production code itself** — extracted straight out of `index.html`, so tests and implementation cannot drift, and CI runs them headlessly on every push; +- The algorithm is pinned by the official RFC 6238 test vectors, and the **367 self-tests in [`tests.html`](tests.html) execute the production code itself** — extracted straight out of `index.html`, so tests and implementation cannot drift, and CI runs them headlessly on every push; +- OTP parameters are validated at every input: omitted values use SHA-1 / 6 digits / 30 seconds; explicit invalid algorithms, digit counts (6–8), or periods (10–300 integer seconds) are rejected. Deduplication includes all three parameters, with exact label/issuer matching. - Unsupported means refused: HOTP links are **rejected outright** (not silently computed as TOTP), and unknown algorithms / digit counts in migration QRs are **dropped** (not guessed at); - On load, it compares your device clock against the server's `Date` response header and **warns loudly** when it's ≥10s off. Clock skew is the most invisible way TOTP fails — and most tools stay silent about it. @@ -191,7 +192,7 @@ A tool that touches secrets deserves to be read before it's trusted. Easy2FA kee - All application logic lives in **one [`index.html`](index.html)** (~1,500 lines — UI templates and both languages included); - **No build step, no `node_modules`** — every line you read on GitHub is exactly what your browser executes; - The entire supply chain is four files under `vendor/`: React, ReactDOM (official UMD builds) and two QR libraries (qrcode.js to generate, jsQR as the decode fallback); -- Open `/tests.html` on your own deployment and **run all 98 self-tests on the spot** — it fetches and tests the very `index.html` you're using. +- Open `/tests.html` on your own deployment and **run all 367 self-tests on the spot** — it fetches and tests the very `index.html` you're using. ## Security notes diff --git a/README.zh-CN.md b/README.zh-CN.md index ea52f6e..a004f57 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -14,7 +14,7 @@ [](LICENSE) [](#部署说明) [](#工作原理) -[](tests.html) +[](tests.html) [](https://github.com/zeropl/2FA/actions/workflows/tests.yml) [English](README.md) · 简体中文 @@ -69,7 +69,8 @@ Easy2FA 把「账号」变回「链接」: 对 2FA 工具来说,**「淡定地显示一个错码」比崩溃更恶劣**——你会拿着错码反复重试,直到把账号锁死。所以: -- 算法用 RFC 6238 官方测试向量锁死;[`tests.html`](tests.html) 的 **98 项自测直接提取生产代码执行**——测的就是线上跑的那份,测试与实现不可能漂移,且每次 push 由 CI 无头跑一遍; +- 算法用 RFC 6238 官方测试向量锁死;[`tests.html`](tests.html) 的 **367 项自测直接提取生产代码执行**——测的就是线上跑的那份,测试与实现不可能漂移,且每次 push 由 CI 无头跑一遍; +- 所有入口统一校验 OTP 参数:省略时使用 SHA-1 / 6 位 / 30 秒;显式无效算法、位数(6–8)或周期(10–300 整数秒)会被拒绝。去重包含这三个参数,并保留标签/发行者的精确匹配语义。 - 不支持就明说:HOTP 链接**直接拒绝**(而不是按 TOTP 硬算一个错的),迁移码里未知的算法 / 位数**直接丢弃**(而不是猜个默认值); - 打开页面会和服务器的 `Date` 响应头**对表**:设备时钟偏差 ≥10 秒就在页面顶端警告「验证码可能无效」。时钟不准是 TOTP 最隐蔽的翻车方式,大多数工具对此保持沉默。 @@ -191,7 +192,7 @@ python3 -m http.server 8000 - 全部应用逻辑在**一个 [`index.html`](index.html)** 里(约 1500 行,界面模板和中英文案都算上); - **没有构建步骤、没有 `node_modules`**——你在 GitHub 上读到的每一行,就是浏览器里执行的每一行; - 整条供应链只有 `vendor/` 下四个文件:React、ReactDOM(官方 UMD 构建)和两个二维码库(生成用 qrcode.js,识别兜底用 jsQR); -- 在你的部署上打开 `/tests.html`,**当场跑 98 项自测**——它 fetch 并测试的,正是你此刻在用的那份 `index.html`。 +- 在你的部署上打开 `/tests.html`,**当场跑 367 项自测**——它 fetch 并测试的,正是你此刻在用的那份 `index.html`。 ## 安全须知 diff --git a/index.html b/index.html index 89e8d6a..9800edd 100644 --- a/index.html +++ b/index.html @@ -383,6 +383,7 @@ clipboardNo: '此浏览器不支持读取剪贴板,请手动粘贴', clipboardFail: '读取剪贴板失败(可能需要授权)', clipboardNoLinks: '剪贴板中未识别到账号链接', scanNoAccount: '二维码中未识别到账号', scanNoSupport: '扫码组件加载失败(离线且未缓存?),请直接粘贴 otpauth:// 链接', qrImported: '已从二维码导入 ✓', qrNotFound: '未识别到二维码', qrFail: '二维码解析失败', qrUnavailable: '二维码不可用', qrGenFail: '二维码生成失败', + errDigits: '验证码位数无效:必须是 6 到 8 的整数', errPeriod: '验证码周期无效:必须是 10 到 300 秒的整数', errAlgorithm: '不支持该算法:仅支持 SHA-1、SHA-256 和 SHA-512', errInvalid: '密钥无效:只能含 A–Z 和 2–7(base32)', errHotp: '暂不支持 HOTP(基于计数器)链接,本工具只算 TOTP', errNoSecret: 'otpauth 链接里没有有效的 secret', errBookmarkBad: '链接里的密钥无效或被截断,请检查后重新粘贴', titleApp: '2FA', titleBoard: '多号看板 · 2FA', titleSuffix: ' · 2FA', }, @@ -423,6 +424,7 @@ clipboardNo: 'This browser can’t read the clipboard — paste manually', clipboardFail: 'Clipboard read failed (permission needed?)', clipboardNoLinks: 'No account links in the clipboard', scanNoAccount: 'No account found in the QR', scanNoSupport: 'QR decoder failed to load (offline and not cached?) — paste the otpauth:// link directly', qrImported: 'Imported from QR ✓', qrNotFound: 'No QR detected', qrFail: 'Failed to parse the QR', qrUnavailable: 'QR unavailable', qrGenFail: 'Failed to generate the QR', + errDigits: 'Invalid OTP digits: use a whole number from 6 to 8', errPeriod: 'Invalid OTP period: use a whole number from 10 to 300 seconds', errAlgorithm: 'Unsupported OTP algorithm: use SHA-1, SHA-256 or SHA-512', errInvalid: 'Invalid secret: only A–Z and 2–7 (base32) allowed', errHotp: 'HOTP (counter-based) links aren’t supported — this tool only does TOTP', errNoSecret: 'No valid secret in the otpauth link', errBookmarkBad: 'The secret in the link is invalid or truncated — check and paste again', titleApp: '2FA', titleBoard: 'Board · 2FA', titleSuffix: ' · 2FA', }, @@ -508,6 +510,8 @@ } } componentWillUnmount() { + this._unmounted = true; + this._codeRequest = (this._codeRequest || 0) + 1; clearInterval(this._tick); clearInterval(this._anim); clearTimeout(this._parseT); @@ -551,6 +555,7 @@ } loadFromHash() { + this.invalidateCodeWork(); const raw = (location.hash || '').replace(/^#/, ''); const params = new URLSearchParams(raw); const secret = (params.get('secret') || '').trim(); @@ -559,16 +564,21 @@ if (secret && this.base32Decode(secret)) { const label = params.get('label') || ''; const issuer = params.get('issuer') || ''; - const digits = this.clampDigits(params.get('digits')); - const period = this.clampPeriod(params.get('period')); - const algorithm = this.normAlgo(params.get('algorithm')); + const otp = this.otpParams(params.get('digits') ?? undefined, params.get('period') ?? undefined, params.get('algorithm') ?? undefined); + if (!otp.valid) { + this._lastPrevCounter = null; + this.setState({ mode: 'setup', secret: '', code: '', inputValue: 'otpauth://totp/' + encodeURIComponent(label) + '?' + params.toString(), inputLabel: label, parsed: null, previewCode: '', shownDigits: null, animating: false, showQr: false, error: this.otpParamError(otp.reason) }); + document.title = this.t().titleApp; + return; + } + const { digits, period, algorithm } = otp; const present = params.get('present') === '1' || params.get('view') === 'code'; const nolabel = params.get('nolabel') === '1'; this._lastCounter = null; this.setState({ mode: 'view', secret, label, issuer, digits, period, algorithm, present, nolabel, code: '', copied: false, shownDigits: null, animating: false, showQr: false }, () => this.refresh()); document.title = ((nolabel ? '' : (label || issuer)) || this.t().code) + this.t().titleSuffix; } else if (boardParam) { - const accts = this.decodeBoardParam(boardParam).map((a) => this.normalizeAccount(a)); + const accts = this.decodeBoardParam(boardParam).map((a) => this.normalizeAccount(a)).filter(Boolean); if (accts.length) { this._boardCounters = []; this.saveBoard(accts); @@ -590,6 +600,7 @@ } enterBoardOrSetup() { + this.invalidateCodeWork(); const cached = this.loadBoard(); if (cached.length) { this._boardCounters = []; @@ -601,13 +612,36 @@ } } - clampDigits(d) { d = parseInt(d); return (Number.isFinite(d) && d >= 6 && d <= 8) ? d : 6; } - clampPeriod(p) { p = parseInt(p); return (Number.isFinite(p) && p >= 10 && p <= 300) ? p : 30; } + // Defaults apply only to omitted parameters. Explicit invalid values must never produce a guessed code. + clampDigits(d) { + if (d === undefined) return 6; + if (typeof d === 'string') { if (!/^\d+$/.test(d.trim())) return null; d = Number(d.trim()); } + return Number.isInteger(d) && d >= 6 && d <= 8 ? d : null; + } + clampPeriod(p) { + if (p === undefined) return 30; + if (typeof p === 'string') { if (!/^\d+$/.test(p.trim())) return null; p = Number(p.trim()); } + return Number.isInteger(p) && p >= 10 && p <= 300 ? p : null; + } normAlgo(a) { - if (!a) return 'SHA-1'; - const n = String(a).toUpperCase().replace(/[^0-9]/g, ''); - return n === '256' ? 'SHA-256' : n === '512' ? 'SHA-512' : 'SHA-1'; + if (a === undefined) return 'SHA-1'; + if (typeof a !== 'string') return null; + const match = /^(?:SHA-?)?(1|256|512)$/i.exec(a.trim()); + return match ? 'SHA-' + match[1] : null; + } + + otpParams(digits, period, algorithm) { + digits = this.clampDigits(digits); + period = this.clampPeriod(period); + algorithm = this.normAlgo(algorithm); + const reason = digits === null ? 'digits' : period === null ? 'period' : algorithm === null ? 'algorithm' : ''; + return { digits, period, algorithm, valid: !reason, reason }; + } + + otpParamError(reason) { + const keys = { digits: 'errDigits', period: 'errPeriod', algorithm: 'errAlgorithm' }; + return this.t()[keys[reason]] || ''; } base32Decode(b32) { @@ -625,6 +659,9 @@ } async totp(secret, time, digits, period, algorithm) { + const params = this.otpParams(digits, period, algorithm); + if (!params.valid) throw new Error('Invalid OTP ' + params.reason); + digits = params.digits; period = params.period; algorithm = params.algorithm; const key = this.base32Decode(secret); if (!key) throw new Error('invalid'); const counter = Math.floor(time / period); @@ -638,7 +675,15 @@ return String(bin % Math.pow(10, digits)).padStart(digits, '0'); } - animateTo(code) { + invalidateCodeWork() { + this._codeRequest = (this._codeRequest || 0) + 1; + this._boardRequests = []; + clearInterval(this._anim); + this._animCode = null; + this.setState({ code: '', previewCode: '', shownDigits: null, animating: false }); + } + + animateTo(code, current) { if (!code) return; clearInterval(this._anim); const n = code.length; @@ -647,6 +692,7 @@ const rand = () => String(Math.floor(Math.random() * 10)); this.setState({ animating: true, shownDigits: code.split('').map(rand) }); this._anim = setInterval(() => { + if (!current(this.state)) { clearInterval(this._anim); return; } const el = performance.now() - start; const shown = []; let done = true; for (let i = 0; i < n; i++) { @@ -659,22 +705,38 @@ } async refresh() { - if (this.state.mode !== 'view' || !this.state.secret) return; - try { - const code = await this.totp(this.state.secret, Date.now() / 1000, this.state.digits, this.state.period, this.state.algorithm); - if (code !== this._animCode) { this._animCode = code; this.animateTo(code); } - this.setState({ code }); - } catch (e) { this.setState({ code: '' }); } + const request = this._codeRequest = (this._codeRequest || 0) + 1; + const a = this.state; + if (a.mode !== 'view' || !a.secret) return; + clearInterval(this._anim); this._animCode = null; + this.setState({ code: '', shownDigits: null, animating: false }); + const now = Date.now() / 1000, identity = this.otpIdentity(a); + const current = (s) => !this._unmounted && request === this._codeRequest && s.mode === 'view' && + this.otpIdentity(s) === identity && Math.floor(Date.now() / 1000 / a.period) === Math.floor(now / a.period); + let code = ''; + try { code = await this.totp(a.secret, now, a.digits, a.period, a.algorithm); } catch (e) {} + if (this._unmounted) return; + this.setState((s) => current(s) ? { code } : null, () => { + if (current(this.state) && code && code !== this._animCode) { this._animCode = code; this.animateTo(code, current); } + }); } async refreshPreview() { + const request = this._codeRequest = (this._codeRequest || 0) + 1; const p = this.state.parsed; + if (this.state.mode !== 'setup') return; if (!p || !p.secret) { this.setState({ previewCode: '', shownDigits: null }); return; } - try { - const code = await this.totp(p.secret, Date.now() / 1000, p.digits, p.period, p.algorithm); - if (code !== this._animCode) { this._animCode = code; this.animateTo(code); } - this.setState({ previewCode: code }); - } catch (e) { this.setState({ previewCode: '' }); } + clearInterval(this._anim); this._animCode = null; + this.setState({ previewCode: '', shownDigits: null, animating: false }); + const now = Date.now() / 1000; + const current = (s) => !this._unmounted && request === this._codeRequest && s.mode === 'setup' && + s.parsed === p && Math.floor(Date.now() / 1000 / p.period) === Math.floor(now / p.period); + let code = ''; + try { code = await this.totp(p.secret, now, p.digits, p.period, p.algorithm); } catch (e) {} + if (this._unmounted) return; + this.setState((s) => current(s) ? { previewCode: code } : null, () => { + if (current(this.state) && code && code !== this._animCode) { this._animCode = code; this.animateTo(code, current); } + }); } tick() { @@ -713,9 +775,9 @@ if (!issuer) issuer = path.split(':')[0].trim(); label = path.split(':').slice(1).join(':').trim(); } else { label = path; } - digits = this.clampDigits(sp.get('digits')); - period = this.clampPeriod(sp.get('period')); - algorithm = this.normAlgo(sp.get('algorithm')); + const otp = this.otpParams(sp.get('digits') ?? undefined, sp.get('period') ?? undefined, sp.get('algorithm') ?? undefined); + if (!otp.valid) return { secret, label, issuer, ...otp }; + digits = otp.digits; period = otp.period; algorithm = otp.algorithm; } catch (e) { return { secret: '', valid: false }; } } else { secret = raw.replace(/\s/g, ''); @@ -733,15 +795,16 @@ } commitParse(v) { + this.invalidateCodeWork(); const parsed = this.parseInput(v); const ok = parsed && parsed.valid; this._lastPrevCounter = null; this._animCode = null; let error = ''; if (v.trim() && !ok) { - error = (parsed && parsed.reason === 'hotp') + error = (parsed && this.otpParamError(parsed.reason)) || ((parsed && parsed.reason === 'hotp') ? this.t().errHotp - : (/^otpauth:\/\//i.test(v.trim()) ? this.t().errNoSecret : this.t().errInvalid); + : (/^otpauth:\/\//i.test(v.trim()) ? this.t().errNoSecret : this.t().errInvalid)); } this.setState({ parsed: ok ? parsed : null, @@ -891,6 +954,7 @@ } addAnother() { + this.invalidateCodeWork(); this._animCode = null; try { history.replaceState(null, '2FA', location.pathname + location.search); } catch (e) {} this.setState({ mode: 'setup', secret: '', inputValue: '', inputLabel: '', parsed: null, previewCode: '', error: '', shownDigits: null, animating: false, showQr: false, importMsg: '' }); @@ -898,6 +962,8 @@ } goSetup() { + this.invalidateCodeWork(); + this._lastPrevCounter = null; this._animCode = null; try { history.replaceState(null, '2FA', location.pathname + location.search); } catch (e) {} this.setState({ mode: 'setup', importOpen: false, exportOpen: false }); @@ -905,6 +971,7 @@ } goBoard() { + this.invalidateCodeWork(); let accts = this.state.accounts; if (!accts || !accts.length) accts = this.loadBoard(); this._boardCounters = []; @@ -917,13 +984,23 @@ hashStr(s) { s = s || ''; let h = 2166136261; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); } return (h >>> 0); } pickColor(a) { return this.ACCENTS[this.hashStr((a.issuer || '') + '|' + (a.label || '') + '|' + (a.secret || '')) % this.ACCENTS.length]; } + // Canonical key bytes + complete OTP settings; labels keep their existing exact semantics. + otpIdentity(a) { + const key = this.base32Decode(a.secret || ''); + return JSON.stringify([key ? Array.from(key) : a.secret, this.normAlgo(a.algorithm), this.clampDigits(a.digits), this.clampPeriod(a.period)]); + } + accountIdentity(a) { return JSON.stringify([this.otpIdentity(a), a.label || '', a.issuer || '']); } + normalizeAccount(a) { + if (!a || typeof a.secret !== 'string') return null; + const params = this.otpParams(a.digits, a.period, a.algorithm); + if (!params.valid) return null; + // base32 大小写不敏感——统一成大写,否则同一密钥的两种大小写会去重失败、出两张同码卡 + const secret = a.secret.replace(/\s/g, '').toUpperCase(); + if (!this.base32Decode(secret)) return null; return { - // base32 大小写不敏感——统一成大写,否则同一密钥的两种大小写会去重失败、出两张同码卡 - secret: (a.secret || '').replace(/\s/g, '').toUpperCase(), - label: a.label || '', issuer: a.issuer || '', - digits: this.clampDigits(a.digits), period: this.clampPeriod(a.period), - algorithm: this.normAlgo((a.algorithm || 'SHA-1').replace('SHA-', '')), + secret, label: a.label || '', issuer: a.issuer || '', + digits: params.digits, period: params.period, algorithm: params.algorithm, color: this.pickColor(a), }; } @@ -940,8 +1017,8 @@ if (!raw) return []; const data = JSON.parse(raw); if (!Array.isArray(data)) return []; - return data.map((d) => this.normalizeAccount({ secret: d.s, label: d.l, issuer: d.i, digits: d.d, period: d.p, algorithm: d.a })) - .filter((a) => this.base32Decode(a.secret)); + return data.map((d) => d && this.normalizeAccount({ secret: d.s, label: d.l, issuer: d.i, digits: d.d, period: d.p, algorithm: d.a })) + .filter(Boolean); } catch (e) { return []; } } // 清空是破坏性操作——两步确认:第一次点武装、2s 内再点才真清(仍是一键级,只是防误触) @@ -953,6 +1030,7 @@ this._clearT = setTimeout(() => { this._clearArmed = false; this.setState({ clearArmed: false }); }, 2200); } clearBoard() { + this.invalidateCodeWork(); try { localStorage.removeItem('easy2fa.board'); } catch (e) {} this._boardCounters = []; this.setState({ accounts: [], boardLive: [], importOpen: false, exportOpen: false, boardMsg: this.t().cleared, removeArmedIdx: null }); @@ -977,6 +1055,7 @@ this._removeT = setTimeout(() => this.setState({ removeArmedIdx: null }), 2200); } removeAccount(i) { + this.invalidateCodeWork(); const accts = (this.state.accounts || []).slice(); const rm = accts.splice(i, 1)[0]; if (!rm) { this.setState({ removeArmedIdx: null }); return; } @@ -989,15 +1068,16 @@ addAccounts(list) { if (!list || !list.length) return; + this.invalidateCodeWork(); const MAX = 200; // 上限:避免一份超大文件生成成百上千张卡 + 每秒那么多次 HMAC 拖垮页面 const existing = (this.state.accounts || []).slice(); - const seen = new Set(existing.map((a) => a.secret + '|' + (a.label || '') + '|' + (a.issuer || ''))); + const seen = new Set(existing.map((a) => this.accountIdentity(a))); let added = 0, truncated = false; for (const raw of list) { if (existing.length >= MAX) { truncated = true; break; } const a = this.normalizeAccount(raw); - if (!this.base32Decode(a.secret)) continue; - const k = a.secret + '|' + (a.label || '') + '|' + (a.issuer || ''); + if (!a) continue; + const k = this.accountIdentity(a); if (seen.has(k)) continue; seen.add(k); existing.push(a); added++; } @@ -1039,7 +1119,7 @@ } const seen = new Set(); const dedup = []; for (const a of out) { - const k = a.secret + '|' + (a.label || '') + '|' + (a.issuer || ''); + const k = this.accountIdentity(a); if (seen.has(k)) continue; seen.add(k); dedup.push(a); } return dedup; @@ -1052,10 +1132,11 @@ const params = new URLSearchParams(s.slice(hi + 1)); const secret = (params.get('secret') || '').trim(); if (secret && this.base32Decode(secret)) { + const otp = this.otpParams(params.get('digits') ?? undefined, params.get('period') ?? undefined, params.get('algorithm') ?? undefined); + if (!otp.valid) return null; return { secret, label: params.get('label') || '', issuer: params.get('issuer') || '', - digits: this.clampDigits(params.get('digits')), period: this.clampPeriod(params.get('period')), - algorithm: this.normAlgo(params.get('algorithm')), valid: true, + digits: otp.digits, period: otp.period, algorithm: otp.algorithm, valid: true, }; } } @@ -1099,24 +1180,25 @@ // 从一条记录(JSON 对象 / CSV 行)取账号——键名大小写不敏感、容忍常见别名;secret 非法则丢弃 accountFromRecord(obj) { if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return null; - const pick = (names) => { + const pick = (names, raw) => { for (const k of Object.keys(obj)) { if (names.includes(k.trim().toLowerCase())) { const v = obj[k]; + if (raw) return v; if (v != null && typeof v !== 'object') return String(v).trim(); } } - return ''; + return raw ? undefined : ''; }; const secret = pick(['secret', 'secretkey', 'seed']).replace(/\s/g, '').toUpperCase(); if (!secret || !this.base32Decode(secret)) return null; + const params = this.otpParams(pick(['digits'], true), pick(['period', 'timer', 'step', 'interval'], true), pick(['algorithm', 'algo', 'hash'], true)); + if (!params.valid) return null; return { secret, label: pick(['label', 'name', 'account', 'accountname', 'username', 'user']), issuer: pick(['issuer', 'service', 'provider', 'site']), - digits: this.clampDigits(pick(['digits'])), - period: this.clampPeriod(pick(['period', 'timer', 'step', 'interval'])), - algorithm: this.normAlgo(pick(['algorithm', 'algo', 'hash'])), + digits: params.digits, period: params.period, algorithm: params.algorithm, valid: true, }; } @@ -1241,8 +1323,8 @@ if (!bytes) return []; const data = JSON.parse(this.utf8(bytes)); if (!Array.isArray(data)) return []; - return data.map((d) => ({ secret: d.s, label: d.l || '', issuer: d.i || '', digits: d.d, period: d.p, algorithm: d.a, valid: true })) - .filter((a) => a.secret && this.base32Decode(a.secret)); + return data.map((d) => d && this.normalizeAccount({ secret: d.s, label: d.l, issuer: d.i, digits: d.d, period: d.p, algorithm: d.a })) + .filter(Boolean).map((a) => ({ ...a, valid: true })); } catch (e) { return []; } } boardUrl() { const p = this.encodeBoardParam(); return p ? (location.origin + location.pathname + '#board=' + p) : ''; } @@ -1270,7 +1352,10 @@ for (let i = 0; i < accts.length; i++) { const a = accts[i]; const counter = Math.floor(now / a.period); - if (this._boardCounters[i] !== counter) { this._boardCounters[i] = counter; need.push(i); } + if (this._boardCounters[i] !== counter) { + this._boardCounters[i] = counter; need.push(i); + live[i] = { ...live[i], code: '', codeFmt: '' }; changed = true; + } const sec = Math.max(1, Math.ceil(a.period - (now % a.period))); const frac = Math.min(1, Math.max(0, sec / a.period)); const prev = live[i] || { code: '', codeFmt: '' }; @@ -1282,16 +1367,30 @@ async computeBoardCodes(indices) { const accts = this.state.accounts; const now = Date.now() / 1000; + const requests = this._boardRequests || (this._boardRequests = []); const results = await Promise.all(indices.map(async (i) => { - const a = accts[i]; - if (!a) return { i, code: '' }; - try { return { i, code: await this.totp(a.secret, now, a.digits, a.period, a.algorithm) }; } - catch (e) { return { i, code: '' }; } + const a = accts[i], token = {}; + requests[i] = token; + if (!a) return null; + let code = ''; + try { code = await this.totp(a.secret, now, a.digits, a.period, a.algorithm); } catch (e) {} + return { i, a, token, code, counter: Math.floor(now / a.period) }; })); - const live = (this.state.boardLive || []).slice(); - for (const r of results) { const prev = live[r.i] || {}; live[r.i] = { ...prev, code: r.code, codeFmt: this.fmtCode(r.code) }; } - this.setState({ boardLive: live }); + if (this._unmounted) return; + this.setState((s) => { + if (this._unmounted || requests !== this._boardRequests || s.mode !== 'board' || s.accounts !== accts) return null; + const live = (s.boardLive || []).slice(); + let changed = false; + for (const r of results) { + if (!r || requests[r.i] !== r.token || s.accounts[r.i] !== r.a || + Math.floor(Date.now() / 1000 / r.a.period) !== r.counter) continue; + live[r.i] = { ...live[r.i], code: r.code, codeFmt: this.fmtCode(r.code) }; + changed = true; + } + return changed ? { boardLive: live } : null; + }); } + fmtCode(code) { if (!code) return ''; if (code.length === 6) return code.slice(0, 3) + ' ' + code.slice(3); diff --git a/tests.html b/tests.html index 4874159..8e22a5c 100644 --- a/tests.html +++ b/tests.html @@ -36,10 +36,12 @@