本地2FA验证器

一个纯本地、离线的2FA(TOTP)验证码生成器

// ==UserScript==
// @name         本地2FA验证器
// @namespace    http://tampermonkey.net/
// @version      12.1
// @description  一个纯本地、离线的2FA(TOTP)验证码生成器
// @author       Gemini
// @match        *://*/*
// @run-at       document-idle
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_deleteValue
// @grant        GM_listValues
// @grant        GM_registerMenuCommand
// @grant        GM_setClipboard
// @grant        GM_addStyle
// @license MIT
// ==/UserScript==

(function() {
    'use strict';

    /*
     * =================================================================================
     * INLINED LIBRARY: otpauth (Clean, unminified, correct source code)
     * =================================================================================
     */
    const otpauth = (() => {
        class OTPAuthError extends Error { constructor(message) { super(message); this.name = this.constructor.name; } }
        class Secret {
            constructor({ buffer } = {}) {
                if (!(buffer instanceof ArrayBuffer)) throw new OTPAuthError("Buffer must be an instance of 'ArrayBuffer'");
                this._buffer = buffer;
            }
            get buffer() { return this._buffer; }
            static fromBase32(base32) {
                const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
                const clean_base32 = base32.toUpperCase().replace(/=+$/, '');
                const bitsPerChar = 5;
                const bytes = new Uint8Array(Math.floor(clean_base32.length * bitsPerChar / 8));
                let bits = 0;
                let value = 0;
                let index = 0;
                for (let i = 0; i < clean_base32.length; i++) {
                    const charIndex = alphabet.indexOf(clean_base32[i]);
                    if (charIndex === -1) throw new OTPAuthError("Invalid Base32 character");
                    value = (value << bitsPerChar) | charIndex;
                    bits += bitsPerChar;
                    if (bits >= 8) {
                        bytes[index++] = (value >>> (bits - 8)) & 255;
                        bits -= 8;
                    }
                }
                return new Secret({ buffer: bytes.buffer });
            }
        }

        class TOTP {
            constructor({ secret, algorithm = 'SHA1', digits = 6, period = 30 } = {}) {
                if (!(secret instanceof Secret)) throw new OTPAuthError("Secret must be an instance of 'Secret'");
                this.secret = secret;
                this.algorithm = algorithm;
                this.digits = digits;
                this.period = period;
            }
            async generate({ timestamp = Date.now() } = {}) {
                const counter = Math.floor(timestamp / 1000 / this.period);
                const counterBuffer = new ArrayBuffer(8);
                const counterView = new DataView(counterBuffer);
                counterView.setUint32(0, Math.floor(counter / 4294967296));
                counterView.setUint32(4, counter & 0xFFFFFFFF);
                const cryptoAlgo = { name: 'HMAC', hash: `SHA-${this.algorithm.slice(3)}` };
                const key = await crypto.subtle.importKey('raw', this.secret.buffer, cryptoAlgo, false, ['sign']);
                const signature = await crypto.subtle.sign('HMAC', key, counterBuffer);
                const signatureView = new DataView(signature);
                const offset = signatureView.getUint8(signatureView.byteLength - 1) & 0x0f;
                let value = signatureView.getUint32(offset);
                value &= 0x7fffffff;
                value %= Math.pow(10, this.digits);
                return value.toString().padStart(this.digits, '0');
            }
        }
        return { Secret, TOTP };
    })();

    /* =================================================================================
     * STYLING (CENTERED & SHARP EDGES)
     * ================================================================================= */
    GM_addStyle(`
        #totp-container { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 320px; background-color: #f9f9f9; border: 1px solid #ccc; border-radius: 0; box-shadow: 0 4px 12px rgba(0,0,0,0.15); z-index: 999999; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; color: #333; }
        #totp-header { padding: 5px 15px; cursor: move; background-color: #efefef; border-bottom: 1px solid #ccc; display: flex; justify-content: space-between; align-items: center; }
        #totp-header h3 { margin: 0; font-size: 14px; font-weight: 600; color: #333333; }
        #totp-close-btn { cursor: pointer; font-size: 20px; font-weight: bold; color: #888; border: none; background: none; }
        #totp-search-container { padding: 1px; border-bottom: 1px solid #ccc; }
        #totp-search-box { width: 100%; box-sizing: border-box; padding: 4px; border: 1px solid #ccc; border-radius: 0; font-size: 4px; background-color: #F9F9F9; color: #333333; height: 26px; }
        #totp-list { list-style: none; padding: 10px; margin: 0; max-height: 400px; overflow-y: auto; transition: height 0.2s; scrollbar-color: #8B8B8B #F9F9F9; }
        .totp-item { display: flex; flex-direction: column; padding: 12px; border-bottom: 1px solid #CCCCCC; }
        .totp-item:last-child { border-bottom: none; }
        .totp-item-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
        .totp-name { font-size: 14px; font-weight: 500; color: #333333; }
        .totp-delete-btn { cursor: pointer; color: #f44336; font-size: 12px; border: 1px solid #f44336; border-radius: 0; padding: 2px 6px; background-color: white; }
        .totp-delete-btn:hover { background-color: #f44336; color: white; }
        .totp-code { font-size: 20px; font-weight: bold; letter-spacing: 2px; color: #007bff; cursor: pointer; text-align: center; min-height: 20px; display: flex; align-items: center; justify-content: center; }
        .totp-progress-bar { width: 100%; height: 4px; background-color: #e9ecef; border-radius: 0; margin-top: 8px; overflow: hidden; }
        .totp-progress { height: 100%; background-color: #007bff; transition: width 1s linear; }
        #totp-add-btn-container { padding: 10px; border-top: 1px solid #ccc; text-align: center; }
        #totp-add-btn { width: 100%; padding: 8px; font-size: 14px; cursor: pointer; background-color: #28a745; color: white; border: none; border-radius: 0; }
    `);

    /* =================================================================================
     * UI & CORE LOGIC
     * ================================================================================= */
    const container = document.createElement('div');
    container.id = 'totp-container';
    container.innerHTML = `
        <div id="totp-header"><h3>本地2FA验证器</h3><button id="totp-close-btn">&times;</button></div>
        <div id="totp-search-container"><input type="text" id="totp-search-box" placeholder="搜索..."></div>
        <div id="totp-list"></div>
        <div id="totp-add-btn-container"><button id="totp-add-btn">添加新密钥</button></div>
    `;
    document.body.appendChild(container);
    container.style.display = 'none';

    const totpList = document.getElementById('totp-list');
    const closeBtn = document.getElementById('totp-close-btn');
    const addBtn = document.getElementById('totp-add-btn');
    const header = document.getElementById('totp-header');
    const searchBox = document.getElementById('totp-search-box');

    let updateInterval = null;
    let secretsMap = new Map();

    async function generateTOTP(secret) {
        try {
            let totp = new otpauth.TOTP({
                secret: otpauth.Secret.fromBase32(secret.replace(/\s/g, ''))
            });
            return await totp.generate();
        } catch (e) {
            console.error(`Failed to generate token for secret "${secret}":`, e);
            return "错误";
        }
    }

    function updateCodeElement(codeElement, code) {
        if (code !== "错误") {
            codeElement.textContent = `${code.substring(0, 3)} ${code.substring(3, 6)}`;
            codeElement.style.color = '#007bff';
        } else {
            codeElement.textContent = "生成失败";
            codeElement.style.color = "#dc3545";
        }
    }

    async function buildAndPopulateUI() {
        const keys = await GM_listValues();
        secretsMap.clear();
        for (const key of keys) {
            if (key.startsWith('totp_')) {
                const name = key.substring(5);
                secretsMap.set(name, await GM_getValue(key));
            }
        }

        totpList.innerHTML = '';

        if (secretsMap.size === 0) {
            totpList.innerHTML = '<p style="text-align:center; color:#888; padding: 20px 0;">暂无密钥,请点击下方按钮添加。</p>';
            return;
        }

        const sortedSecrets = new Map([...secretsMap.entries()].sort());

        for (const [name, secret] of sortedSecrets.entries()) {
            const item = document.createElement('div');
            item.className = 'totp-item';
            item.setAttribute('data-name', name);
            item.innerHTML = `
                <div class="totp-item-header">
                    <span class="totp-name">${name}</span>
                    <button class="totp-delete-btn">删除</button>
                </div>
                <div class="totp-code" title="点击复制">... ...</div>
                <div class="totp-progress-bar"><div class="totp-progress"></div></div>
            `;
            totpList.appendChild(item);

            const codeElement = item.querySelector('.totp-code');
            const initialCode = await generateTOTP(secret);
            updateCodeElement(codeElement, initialCode);

            item.querySelector('.totp-delete-btn').addEventListener('click', async () => {
                if (confirm(`确定要删除密钥 "${name}" 吗?此操作不可撤销!`)) {
                    await GM_deleteValue(`totp_${name}`);
                    hideContainer();
                    showContainer();
                }
            });

            codeElement.addEventListener('click', () => {
                const currentCode = codeElement.textContent.replace(/\s/g, '');
                if (currentCode && currentCode.length === 6 && !isNaN(currentCode)) {
                    GM_setClipboard(currentCode);
                    const originalText = codeElement.textContent;
                    codeElement.textContent = '已复制!';
                    setTimeout(() => { if (codeElement) codeElement.textContent = originalText; }, 1000);
                }
            });
        }
    }

    async function updateUI() {
        const remainingTime = 30 - (Math.floor(Date.now() / 1000) % 30);
        const isNewCycle = remainingTime === 30;

        for (const [name, secret] of secretsMap.entries()) {
            const item = totpList.querySelector(`.totp-item[data-name="${CSS.escape(name)}"]`);
            if (item) {
                const progressElement = item.querySelector('.totp-progress');
                progressElement.style.width = `${(remainingTime / 30) * 100}%`;

                if (isNewCycle) {
                    const codeElement = item.querySelector('.totp-code');
                    const newCode = await generateTOTP(secret);
                    updateCodeElement(codeElement, newCode);
                }
            }
        }
    }

    function filterEntries() {
        const searchTerm = searchBox.value.toLowerCase();
        const items = totpList.querySelectorAll('.totp-item');
        items.forEach(item => {
            const name = item.getAttribute('data-name').toLowerCase();
            if (name.includes(searchTerm)) {
                item.style.display = 'flex';
            } else {
                item.style.display = 'none';
            }
        });
    }

    async function addNewSecret() {
        const name = prompt("请输入此密钥的名称:");
        if (!name) return;
        const secret = prompt("请输入服务提供商的2FA密钥:");
        if (!secret) return;
        if (!/^[A-Z2-7=]+$/i.test(secret.replace(/\s/g, ''))) {
            alert("密钥格式无效。它应该只包含字母A-Z和数字2-7。");
            return;
        }
        await GM_setValue(`totp_${name}`, secret);
        alert(`密钥 "${name}" 已成功添加!`);
        hideContainer();
        showContainer();
    }

    async function showContainer() {
        container.style.top = '50%';
        container.style.left = '50%';
        container.style.transform = 'translate(-50%, -50%)';
        searchBox.value = '';
        container.style.display = 'block';

        await buildAndPopulateUI();

        // --- KEY FIX: Lock the list height after it's populated ---
        const listHeight = totpList.offsetHeight;
        totpList.style.height = `${listHeight}px`;

        if (updateInterval) clearInterval(updateInterval);
        updateInterval = setInterval(updateUI, 1000);
    }

    function hideContainer() {
        container.style.display = 'none';
        if (updateInterval) {
            clearInterval(updateInterval);
            updateInterval = null;
        }
        // --- KEY FIX: Unlock the list height when hidden ---
        totpList.style.height = 'auto';
    }

    function toggleContainer() {
        if (container.style.display === 'none') showContainer();
        else hideContainer();
    }

    GM_registerMenuCommand("显示验证器", toggleContainer);
    GM_registerMenuCommand("添加新密钥", addNewSecret);
    closeBtn.addEventListener('click', hideContainer);
    addBtn.addEventListener('click', addNewSecret);
    searchBox.addEventListener('input', filterEntries);

    let isDragging = false, offsetX, offsetY;
    header.addEventListener('mousedown', (e) => {
        isDragging = true;
        const rect = container.getBoundingClientRect();
        container.style.transform = 'none';
        container.style.left = `${rect.left}px`;
        container.style.top = `${rect.top}px`;
        offsetX = e.clientX - rect.left;
        offsetY = e.clientY - rect.top;
        header.style.cursor = 'grabbing';
    });
    document.addEventListener('mousemove', (e) => {
        if (isDragging) {
            container.style.left = `${e.clientX - offsetX}px`;
            container.style.top = `${e.clientY - offsetY}px`;
        }
    });
    document.addEventListener('mouseup', () => {
        isDragging = false;
        header.style.cursor = 'move';
    });

})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址