// ==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 = `
`;
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.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();
})();