// ==UserScript== // @name TOTP Two-Factor Authentication Helper // @namespace http://tampermonkey.net/ // @version 1.9.0 // @description TOTP助手,支持直接识别二维码,可用于GitHub等需要双因素认证的网站 // @author Bayn-web (https://github.com/bayn-web) // @license MIT // @match *://*/* // @noframes // @grant GM_setValue // @grant GM_getValue // @grant GM_deleteValue // @grant GM_addValueChangeListener // @grant GM_addElement // @grant unsafeWindow // @grant GM_registerMenuCommand // @grant GM_notification // @run-at document-end // @downloadURL https://update.greasyfork.icu/scripts/567509/TOTP%20Two-Factor%20Authentication%20Helper.user.js // @updateURL https://update.greasyfork.icu/scripts/567509/TOTP%20Two-Factor%20Authentication%20Helper.meta.js // ==/UserScript== (function () { "use strict"; // ==================== 加密工具类 ==================== class CryptoUtils { // Base32 解码 static base32Decode(str) { str = str.replace(/=+$/, "").toUpperCase(); const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; const bytes = []; let bits = 0; let value = 0; for (let i = 0; i < str.length; i++) { const c = str[i]; const index = alphabet.indexOf(c); if (index === -1) { throw new Error("Invalid base32 character: " + c); } value = (value << 5) | index; bits += 5; if (bits >= 8) { bytes.push((value >>> (bits - 8)) & 255); bits -= 8; } } return new Uint8Array(bytes); } // 使用 Web Crypto API 实现 HMAC-SHA1 static async hmacSha1(key, message) { const cryptoKey = await crypto.subtle.importKey("raw", key, { name: "HMAC", hash: "SHA-1" }, false, ["sign"]); const signature = await crypto.subtle.sign("HMAC", cryptoKey, message); return new Uint8Array(signature); } // 数字转字节数组(大端序) static intToBytes(num) { const bytes = new Uint8Array(8); for (let i = 7; i >= 0; i--) { bytes[i] = num & 0xff; num = num >>> 8; } return bytes; } static async generateTOTP(secret, counter = 0, digits = 6) { try { // 解码 Base32 密钥 const key = this.base32Decode(secret); // 生成 8 字节计数器 const counterBytes = this.intToBytes(counter); // 计算 HMAC-SHA1 const hmac = await this.hmacSha1(key, counterBytes); // 动态截断 const offset = hmac[hmac.length - 1] & 0x0f; const binary = ((hmac[offset] & 0x7f) << 24) | ((hmac[offset + 1] & 0xff) << 16) | ((hmac[offset + 2] & 0xff) << 8) | (hmac[offset + 3] & 0xff); // 生成指定位数的代码 const otp = binary % Math.pow(10, digits); return otp.toString().padStart(digits, "0"); } catch (e) { console.error("TOTP generation error:", e); throw e; } } // 验证 Base32 格式 static isValidBase32(str) { return /^[A-Z2-7=]+$/i.test(str.replace(/\s/g, "")); } } // ==================== TOTP 生成器 ==================== class TOTPGenerator { constructor() { this.keys = GM_getValue("totp_keys", []); GM_addValueChangeListener("totp_keys", (name, old_value, new_value, remote) => { // 只有当变化来自其他页面时才更新本地数据 if (remote) { this.keys = new_value; // 如果有UI实例,更新界面 if (unsafeWindow.totpUI) { unsafeWindow.totpUI.updateList(); } } }); } async generateTOTP(secret, timeStep = 30, digits = 6) { try { const counter = Math.floor(Date.now() / 1000 / timeStep); return await CryptoUtils.generateTOTP(secret, counter, digits); } catch (e) { console.error("Error generating TOTP:", e); return "ERROR"; } } parseOtpAuthUri(uri) { if (!uri || !uri.startsWith("otpauth://")) { throw new Error("Invalid URI: must start with otpauth://"); } // 移除 'otpauth://' const withoutProtocol = uri.substring(10); // 找到第一个 '/' 的位置(分隔 type 和 label+query) const firstSlashIndex = withoutProtocol.indexOf("/"); if (firstSlashIndex === -1) { throw new Error("Invalid URI: missing path"); } const type = withoutProtocol.substring(0, firstSlashIndex); const pathAndQuery = withoutProtocol.substring(firstSlashIndex); // e.g. "/label?secret=..." if (type !== "totp") { throw new Error(`Only 'totp' is supported, got: ${type}`); } // 分离 label 和 query string const qIndex = pathAndQuery.indexOf("?"); let labelPart, queryPart; if (qIndex === -1) { labelPart = pathAndQuery.substring(1); // remove leading '/' queryPart = ""; } else { labelPart = pathAndQuery.substring(1, qIndex); queryPart = pathAndQuery.substring(qIndex + 1); } const label = decodeURIComponent(labelPart); // 手动解析 query string(或用 URLSearchParams 包装) const params = new URLSearchParams(queryPart); const secret = params.get("secret"); if (!secret) { throw new Error("Missing 'secret' parameter"); } return { label, issuer: params.get("issuer") || "", secret, algorithm: (params.get("algorithm") || "SHA1").toUpperCase(), digits: parseInt(params.get("digits"), 10) || 6, period: parseInt(params.get("period"), 10) || 30, }; } addKey(service, secret, issuer = "") { const newKey = { id: Date.now(), service: service, issuer: issuer, secret: secret.toUpperCase().replace(/\s/g, ""), }; this.keys.push(newKey); this.saveKeys(); return newKey; } removeKey(id) { this.keys = this.keys.filter((key) => key.id !== id); this.saveKeys(); } saveKeys() { GM_setValue("totp_keys", this.keys); } getKeys() { return this.keys; } exportKeys() { return JSON.stringify(this.keys, null, 2); } importKeys(jsonString) { try { const importedKeys = JSON.parse(jsonString); if (Array.isArray(importedKeys)) { this.keys = importedKeys; this.saveKeys(); return true; } return false; } catch (e) { console.error("Error importing keys:", e); return false; } } async getCurrentCodes() { const codes = []; const currentTime = Math.floor(Date.now() / 1000); for (const key of this.keys) { const code = await this.generateTOTP(key.secret, key.period || 30); codes.push({ id: key.id, service: key.service, issuer: key.issuer, code: code, timeRemaining: (key.period || 30) - (currentTime % (key.period || 30)), }); } return codes; } } // ==================== UI 管理器 ==================== class TOTPUI { captureMode = false; constructor(generator) { this.generator = generator; this.isOpen = false; this.updateInterval = null; this.createUI(); } createUI() { this.container = document.createElement("div"); this.container.id = "totp-container"; this.container.innerHTML = `

🔐 TOTP Helper

`; this.addStyles(); document.body.appendChild(this.container); this.bindEvents(); } async showToast(message) { const alert = document.createElement("sl-alert"); alert.variant = "primary"; alert.duration = 3000; alert.closable = true; alert.innerHTML = ` Info
${message} `; document.body.appendChild(alert); await customElements.whenDefined("sl-alert"); alert.toast(); } addStyles() { const style = document.createElement("style"); style.textContent = ` .totp-capture-btn-active { background: linear-gradient(135deg, #6a11cb 0%, #2575fc 100%); } .sl-toast-stack { z-index: 10000; } #totp-container { height: 66vh; position: fixed; top: 6vh; right: 20px; z-index: 10000; } #totp-card { display: flex; flex-direction: column; height: 100%; width: 380px; background: #ffffff; border-radius: 12px; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; overflow: hidden; border: 1px solid #e0e0e0; } #totp-header { display: flex; justify-content: space-between; align-items: center; padding: 20px; background: linear-gradient(135deg, #6a11cb 0%, #2575fc 100%); color: white; } #totp-header h3 { margin: 0; font-size: 18px; } #totp-close-btn { background: rgba(255, 255, 255, 0.2); border: none; font-size: 22px; cursor: pointer; color: white; width: 30px; height: 30px; border-radius: 50%; display: flex; align-items: center; justify-content: center; } #totp-content { overflow: auto; height: 100%; padding: 20px; background: #fafafa; } #totp-add-btn { background: linear-gradient(135deg, #00b09b, #96c93d); color: white; border: none; padding: 12px 20px; border-radius: 8px; cursor: pointer; width: 100%; font-size: 15px; } #totp-add-form { background: white; padding: 20px; border-radius: 8px; margin-top: 15px; border: 1px solid #eaeaea; } #totp-add-form input { width: 100%; padding: 12px; margin: 8px 0; border: 1px solid #ddd; border-radius: 6px; box-sizing: border-box; } #totp-add-form button { padding: 10px 20px; margin-right: 10px; margin-top: 10px; border: none; border-radius: 6px; cursor: pointer; } #totp-save-btn { background: #2196F3; color: white; } #totp-cancel-btn { background: #f44336; color: white; } #totp-parse-uri-btn { background: #9c27b0; color: white; width: 100%; } .totp-item { background: white; padding: 16px; margin: 15px 0; border-radius: 8px; border-left: 5px solid #2196F3; box-shadow: 0 3px 10px rgba(0,0,0,0.08); } .totp-item-header { display: flex; justify-content: space-between; align-items: center; } .totp-service { font-weight: 600; color: #333; } .totp-code { font-family: 'Courier New', monospace; font-size: 24px; letter-spacing: 4px; margin: 12px 0; text-align: center; background: linear-gradient(135deg, #f5f7fa 0%, #e4e7f4 100%); padding: 15px; border-radius: 8px; font-weight: bold; color: #2c3e50; cursor: pointer; } .totp-code:hover { background: linear-gradient(135deg, #e8f4fd 0%, #c2e0ff 100%); } .totp-time { text-align: right; font-size: 13px; color: #666; padding-top: 8px; border-top: 1px dashed #eee; } .totp-delete-btn { background: #ff416c; color: white; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer; } #totp-export-section button { background: #795548; color: white; border: none; padding: 10px 16px; border-radius: 6px; cursor: pointer; margin-right: 10px; margin-top: 10px; } #totp-export-area { width: 100%; height: 100px; margin-top: 10px; border: 1px solid #ddd; border-radius: 6px; font-family: monospace; padding: 10px; box-sizing: border-box; } .time-warning { color: #f44336; font-weight: bold; } `; document.head.appendChild(style); } bindEvents() { document.getElementById("totp-close-btn").addEventListener("click", () => this.toggleUI()); document.getElementById("totp-add-btn").addEventListener("click", () => { document.getElementById("totp-add-form").style.display = document.getElementById("totp-add-form").style.display == "none" ? "block" : "none"; }); document.getElementById("totp-save-btn").addEventListener("click", () => this.saveNewKey()); document.getElementById("totp-cancel-btn").addEventListener("click", () => { document.getElementById("totp-add-form").style.display = "none"; }); document.getElementById("totp-parse-uri-btn").addEventListener("click", () => this.parseUri()); document.getElementById("totp-export-btn").addEventListener("click", () => this.exportData()); document.getElementById("totp-import-btn").addEventListener("click", () => { document.getElementById("totp-import-file").click(); }); document.getElementById("totp-import-file").addEventListener("change", (e) => this.importData(e)); document.getElementById("totp-capture-btn").addEventListener("click", () => { this.startCaptureMode(); }); // 一次性点击监听 const handleClick = async (event) => { if (!this.captureMode) { return; } if (event.target.tagName !== "IMG") { return; } this.captureMode = false; const images = document.querySelectorAll("img"); // 移除高亮 images.forEach((img) => { img.style.outline = ""; img.style.outlineOffset = ""; img.style.cursor = ""; }); if (event.target.tagName === "IMG") { const img = event.target; // 尝试解码 const result = await this.decodeImageFromElement(img); if (result && result.startsWith("otpauth://")) { try { const parsed = this.generator.parseOtpAuthUri(result); document.getElementById("totp-service-name").value = parsed.label || parsed.issuer || "Scanned Account"; document.getElementById("totp-secret-key").value = parsed.secret; document.getElementById("totp-add-form").style.display = "block"; this.showToast("✅ QR code scanned! Fill in the form and click Save."); } catch (parseErr) { this.showToast("⚠️ Scanned QR, but invalid otpauth format."); console.error("Parse error:", parseErr); } } else if (result) { this.showToast("ℹ️ QR code found, but not a TOTP URI."); console.log("Non-TOTP QR content:", result); } else { this.showToast("❌ No QR code detected in this image."); } } }; document.addEventListener("click", handleClick); } saveNewKey() { const serviceName = document.getElementById("totp-service-name").value.trim(); const secretKey = document.getElementById("totp-secret-key").value.trim(); if (serviceName && secretKey) { if (CryptoUtils.isValidBase32(secretKey)) { this.generator.addKey(serviceName, secretKey); document.getElementById("totp-service-name").value = ""; document.getElementById("totp-secret-key").value = ""; document.getElementById("totp-add-form").style.display = "none"; this.updateList(); GM_notification({ text: "Account added successfully!", timeout: 2000 }); } else { this.showToast("Invalid Base32 secret key. Please check and try again."); } } else { this.showToast("Please enter both service name and secret key"); } } parseUri() { const uri = document.getElementById("totp-otpauth-uri").value.trim(); const parsed = this.generator.parseOtpAuthUri(uri); if (parsed) { document.getElementById("totp-service-name").value = parsed.label; document.getElementById("totp-secret-key").value = parsed.secret; GM_notification({ text: "URI parsed successfully!", timeout: 2000 }); } else { this.showToast("Invalid otpauth URI"); } } exportData() { const exportArea = document.getElementById("totp-export-area"); if (exportArea.style.display === "none") { exportArea.value = this.generator.exportKeys(); exportArea.style.display = "block"; exportArea.select(); } else { exportArea.style.display = "none"; } } importData(e) { const file = e.target.files[0]; if (file) { const reader = new FileReader(); reader.onload = (event) => { if (this.generator.importKeys(event.target.result)) { GM_notification({ text: "Import successful!", timeout: 2000 }); this.updateList(); } else { this.showToast("Import failed. Invalid file format."); } }; reader.readAsText(file); } } async decodeImageFromElement(imgElement) { // 创建 canvas const canvas = document.createElement("canvas"); const ctx = canvas.getContext("2d"); const { naturalWidth: width, naturalHeight: height } = imgElement; canvas.width = width; canvas.height = height; try { // 绘制图片到 canvas ctx.drawImage(imgElement, 0, 0, width, height); // 获取像素数据(⚠️ 如果图片跨域且无 CORS,这里会抛错) const imageData = ctx.getImageData(0, 0, width, height); // 使用 jsQR 识别 const code = unsafeWindow.jsQR(imageData.data, width, height); if (code && code.data) { return code.data.trim(); } return null; } catch (err) { console.warn("Canvas tainted or decoding failed:", err); return null; } } async startCaptureMode() { try { await this.loadJsQR(); } catch (err) { this.showToast("❌ Failed to load QR decoder. Please try again."); return; } this.captureMode = true; // 高亮所有图片 const images = document.querySelectorAll("img"); console.log("Starting capture mode...", images); images.forEach((img) => { img.style.outline = "3px solid #FF5722"; img.style.outlineOffset = "2px"; img.style.cursor = "pointer"; }); // 临时提示 this.showToast("Click on a QR code image to scan it!"); } async toggleUI() { this.container.style.display = this.container.style.display === "none" ? "block" : "none"; this.isOpen = !this.isOpen; if (this.isOpen) { await this.updateList(); this.updateInterval = setInterval(() => { this.updateList().catch((err) => console.error("Error updating TOTP list:", err)); }, 1000); } else if (this.updateInterval) { clearInterval(this.updateInterval); this.updateInterval = null; } } async loadJsQR() { if (unsafeWindow.jsQR) return; // 已加载 return new Promise((resolve, reject) => { const script = document.createElement("script"); script.src = "https://cdn.jsdelivr.net/npm/jsqr@1.3.1/dist/jsQR.min.js"; script.onload = () => { resolve(); }; script.onerror = () => reject(new Error("Failed to load jsQR")); document.head.appendChild(script); }); } async updateList() { const listContainer = document.getElementById("totp-list"); const codes = await this.generator.getCurrentCodes(); listContainer.innerHTML = ""; if (codes.length === 0) { listContainer.innerHTML = '

No accounts added yet.

'; return; } codes.forEach((item) => { const itemDiv = document.createElement("div"); itemDiv.className = "totp-item"; const timeClass = item.timeRemaining <= 5 ? "time-warning" : ""; itemDiv.innerHTML = `
${item.service || item.issuer || "Unnamed"}
${item.code}
${item.timeRemaining}s remaining
`; itemDiv.querySelector(".totp-delete-btn").addEventListener("click", (e) => { e.stopPropagation(); if (confirm("Remove this account?")) { this.generator.removeKey(item.id); this.updateList(); } }); itemDiv.querySelector(".totp-code").addEventListener("click", (e) => { e.stopPropagation(); navigator.clipboard.writeText(item.code).then(() => { const originalText = e.target.textContent; e.target.textContent = "✓ COPIED"; setTimeout(() => { e.target.textContent = originalText; }, 1000); }); }); listContainer.appendChild(itemDiv); }); } } // ==================== 初始化 ==================== GM_addElement("link", { id: "shoelace-styles", rel: "stylesheet", href: "https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.20.1/cdn/themes/light.css", }); GM_addElement("script", { type: "module", src: "https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.20.1/cdn/shoelace-autoloader.js", }); async function initAfterShoelaceReady() { let totpUI; try { const totpGenerator = new TOTPGenerator(); totpUI = new TOTPUI(totpGenerator); unsafeWindow.totpUI = totpUI; // 创建浮动按钮 const globalBtn = document.createElement("button"); globalBtn.textContent = "🔐"; globalBtn.style.cssText = ` position: fixed; bottom: 20px; right: 20px; z-index: 9999; background: linear-gradient(135deg, #6a11cb 0%, #2575fc 100%); color: white; border: none; border-radius: 50%; width: 60px; height: 60px; cursor: pointer; box-shadow: 0 6px 20px rgba(37, 117, 252, 0.4); font-size: 24px; display: flex; align-items: center; justify-content: center; transition: all 0.3s ease; `; globalBtn.title = "TOTP Helper"; globalBtn.addEventListener("click", () => totpUI.toggleUI()); globalBtn.addEventListener("mouseenter", () => { globalBtn.style.transform = "scale(1.1)"; }); globalBtn.addEventListener("mouseleave", () => { globalBtn.style.transform = "scale(1)"; }); document.body.appendChild(globalBtn); document.getElementById("totp-container").style.display = "none"; GM_registerMenuCommand("🔐 Open TOTP Helper", () => totpUI.toggleUI()); } catch (err) { console.error("Failed to initialize TOTP UI:", err); totpUI.showToast("⚠️ Failed to load TOTP Helper UI. Please refresh the page."); } } // 启动初始化 initAfterShoelaceReady(); })();