TOTP.js — 纯 Windows 终端双因素验证码生成器
一个用 JScript 编写的 TOTP(基于时间的一次性密码)验证码生成器,无需安装任何额外软件,在 cmd 终端中实时显示所有账号的 6 位验证码。
特性
- 零依赖 — SHA-1、HMAC-SHA1、Base32 解码全部用 JScript 手写实现,不调用 PowerShell、Python 或任何外部工具
- ANSI 彩色界面 — 验证码颜色随剩余时间动态变化:绿色 → 黄色 → 红色
- 原地刷新无闪烁 — 使用 ANSI 光标控制序列原地重绘,每秒平滑刷新
- 纯文本模式 — --plain 选项适配不支持 ANSI 的终端,每 15 秒输出一次纯文本快照
- 标准 otpauth URI — 直接兼容 Google Authenticator 导出格式
- 进度条倒计时 — 彩色模式用 ANSI 背景色,纯文本模式用 ASCII 字符
- 隐藏光标 — 彩色模式下隐藏光标,界面干净无闪烁
运行环境
Windows 7 及以上(需内置 cscript.exe,所有 Windows 版本自带),cmd.exe 或 Windows Terminal。
使用方法
1. 将你的 TOTP 密钥保存为 TOTP.txt,放在与 TOTP.js 同目录下
2. 打开 cmd 运行 cscript //nologo TOTP.js
3. 按 Ctrl+C 退出
Win10修改注册表来支持颜色控制符(感谢cutebe):
- reg add "HKCU\Console" /v "VirtualTerminalLevel" /t REG_DWORD /d 1 /f
复制代码
纯文本模式
如果终端不支持 ANSI 控制序列(颜色/光标定位),加 --plain 参数:
- cscript //nologo TOTP.js --plain
复制代码
纯文本模式下:无颜色、无光标控制、不含任何 ANSI 转义序列,每 15 秒输出一次完整的验证码快照(不清屏,历史输出自然向下滚动),进度条用 ASCII 字符 # 和 - 绘制。
TOTP.txt 格式
每行一个 otpauth URI(Google Authenticator 标准导出格式),以 # 开头的行视为注释跳过:
- otpauth://totp/GitHub:username?secret=JBSWY3DPEHPK3PXP&issuer=GitHub
- otpauth://totp/Google:user@gmail.com?secret=JBSWY3DPEHPK3PXP&issuer=Google
- # 这是一行注释,会被跳过
- otpauth://totp/Microsoft:user@outlook.com?secret=JBSWY3DPEHPK3PXP&issuer=Microsoft
复制代码
支持的参数(均为可选,缺省使用标准值):
- secret Base32 编码的密钥(必填) 无默认值
- issuer 显示名称 从 label 提取
- digits 验证码位数 6
- period 时间步长(秒) 30
- algorithm 哈希算法(仅 SHA1) SHA1
复制代码
界面说明
彩色模式(默认):
- TOTP Authenticator 02:16:51
- ==========================================================
- GitHub 086483 ████████░░░░░░░░░░░░ 9s
- JetBrains Account 524254 ████████░░░░░░░░░░░░ 9s
- Google 512861 ████████░░░░░░░░░░░░ 9s
- ----------------------------------------------------------
- Refresh 1s | Ctrl+C to exit
复制代码
纯文本模式(--plain):
- TOTP Authenticator 02:16:51
- ==========================================================
- GitHub 086483 [########------------] 9s
- JetBrains Account 524254 [########------------] 9s
- Google 512861 [########------------] 9s
- ----------------------------------------------------------
- Refresh 15s | Ctrl+C to exit
复制代码
- 账号名左对齐 24 字符宽
- 验证码 6 位数字
- 进度条 20 格,填充部分表示已用时间
- 剩余秒数右对齐
技术实现
整个项目用纯 JScript(Windows Script Host)实现,不依赖任何外部库或工具:
- Base32 解码 — 按 RFC 4648 标准将密钥字符串转为字节数组
- SHA-1 — 完整实现 RFC 3174 规范,含消息填充和 80 轮压缩
- HMAC-SHA1 — 按 RFC 2104 实现,ipad/opad 异或 + 双哈希
- TOTP — 按 RFC 6238 实现,动态截断取 6 位数字
- ANSI 渲染 — ESC 序列控制光标定位、行清除、前景/背景色(彩色模式)
- 纯文本降级 — --plain 模式下完全不含 ANSI 转义,每 15 秒输出纯文本快照 + ASCII 进度条
限制
- 仅支持 SHA-1 算法(覆盖 99% 的 TOTP 服务)
- 彩色模式需在 cmd.exe 或 Windows Terminal 中直接运行,通过管道重定向会看到原始转义码
- 不支持二维码扫描,需手动准备 TOTP.txt
源码
TOTP.js(主程序)
- // TOTP.js - ANSI TOTP Authenticator
- // Run: cscript //nologo TOTP.js [path\to\totp.txt] [--plain]
- // --plain: non-color mode, 15s interval, output snapshots without clearing screen (for ANSI-unsupported terminals)
- // Ctrl+C to exit
- var ESC = String.fromCharCode(27);
- (function() {
- var fso = new ActiveXObject("Scripting.FileSystemObject");
- var args = WScript.Arguments;
- var scriptDir = fso.GetParentFolderName(WScript.ScriptFullName);
- var txtPath = null;
- var plain = false;
- for (var i = 0; i < args.length; i++) {
- if (args(i).toLowerCase() === "--plain") {
- plain = true;
- } else if (!txtPath) {
- txtPath = args(i);
- }
- }
- if (!txtPath) txtPath = fso.BuildPath(scriptDir, "TOTP.txt");
- if (!fso.FileExists(txtPath)) {
- WScript.Echo("Error: " + txtPath + " not found");
- WScript.Quit(1);
- }
- var entries = parseFile(fso, txtPath);
- if (entries.length === 0) {
- WScript.Echo("Error: No valid entries in " + txtPath);
- WScript.Quit(1);
- }
- var shell = plain ? new ActiveXObject("WScript.Shell") : null;
- var refreshMs = plain ? 15000 : 1000;
- if (!plain) {
- WScript.StdOut.Write(ESC + "[?25l"); // hide cursor
- }
- try {
- var firstDraw = true;
- while (true) {
- if (plain) {
- }
- WScript.StdOut.Write(render(entries, firstDraw, plain));
- firstDraw = false;
- WScript.Sleep(refreshMs);
- }
- } finally {
- if (!plain) {
- WScript.StdOut.Write(ESC + "[?25h" + ESC + "[0m\r\n"); // show cursor + reset
- }
- }
- })();
- function parseFile(fso, path) {
- var file = fso.OpenTextFile(path, 1, false, 0);
- var content = file.ReadAll();
- file.Close();
- var lines = content.split(/\r?\n/);
- var entries = [];
- for (var i = 0; i < lines.length; i++) {
- var line = lines[i].replace(/^\s+|\s+$/g, "");
- if (!line || line.charAt(0) === "#") continue;
- if (line.indexOf("otpauth://") !== 0) continue;
- var secret = extractParam(line, "secret");
- if (!secret) continue;
- var issuer = extractParam(line, "issuer");
- var label = extractLabel(line);
- var digits = parseInt(extractParam(line, "digits") || "6", 10);
- var period = parseInt(extractParam(line, "period") || "30", 10);
- var name = urlDecode(issuer ? issuer : (label ? label : "Unknown"));
- entries.push({ name: name, secret: secret, digits: digits, period: period });
- }
- return entries;
- }
- function render(entries, firstDraw, plain) {
- var now = Math.floor(new Date().getTime() / 1000);
- var d = new Date();
- var timeStr = pad2(d.getHours()) + ":" + pad2(d.getMinutes()) + ":" + pad2(d.getSeconds());
- var W = 58;
- var out;
- if (plain) {
- out = "";
- } else {
- out = firstDraw ? (ESC + "[2J" + ESC + "[H") : (ESC + "[H");
- }
- // Header
- out += nl(plain);
- if (plain) {
- out += " TOTP Authenticator" + spaces(W - 20 - 8) + timeStr + "\r\n";
- out += " " + repeat("=", W) + "\r\n";
- } else {
- out += ESC + "[2K " + ESC + "[1;36m" + "TOTP Authenticator" + ESC + "[0m" +
- spaces(W - 20 - 8) + ESC + "[90m" + timeStr + ESC + "[0m\r\n";
- out += ESC + "[2K " + ESC + "[90m" + repeat("=", W) + ESC + "[0m\r\n";
- }
- out += nl(plain);
- // Entries
- for (var i = 0; i < entries.length; i++) {
- var e = entries[i];
- var remaining = e.period - (now % e.period);
- var counter = Math.floor(now / e.period);
- var code = computeTOTP(e.secret, counter, e.digits);
- var barW = 20;
- var elapsed = e.period - remaining;
- var filled = Math.floor(elapsed / e.period * barW);
- if (filled > barW) filled = barW;
- if (plain) {
- var bar = "[";
- for (var j = 0; j < barW; j++) bar += (j < filled) ? "#" : "-";
- bar += "]";
- out += " " + padRight(e.name, 24) + " " + code + " " + bar + " " +
- padLeft(String(remaining), 2) + "s\r\n";
- } else {
- var codeSgr, barSgr;
- if (remaining <= 5) { codeSgr = "1;31"; barSgr = "41"; }
- else if (remaining <= 10) { codeSgr = "1;33"; barSgr = "43"; }
- else { codeSgr = "1;32"; barSgr = "42"; }
- var bar2 = ESC + "[" + barSgr + "m" + spaces(filled) + ESC + "[0m" +
- ESC + "[100m" + spaces(barW - filled) + ESC + "[0m";
- out += ESC + "[2K";
- out += " " + ESC + "[97m" + padRight(e.name, 24) + ESC + "[0m";
- out += " " + ESC + "[" + codeSgr + "m" + code + ESC + "[0m";
- out += " " + bar2;
- out += " " + ESC + "[90m" + padLeft(String(remaining), 2) + "s" + ESC + "[0m";
- out += "\r\n";
- }
- }
- // Footer
- out += nl(plain);
- if (plain) {
- out += " " + repeat("-", W) + "\r\n";
- out += " Refresh 15s | Ctrl+C to exit\r\n";
- } else {
- out += ESC + "[2K " + ESC + "[90m" + repeat("-", W) + ESC + "[0m\r\n";
- out += ESC + "[2K " + ESC + "[90m" + "Refresh 1s | Ctrl+C to exit" + ESC + "[0m\r\n";
- }
- return out;
- }
- // ===== plain-mode newline helper =====
- function nl(plain) {
- return plain ? "\r\n" : (ESC + "[2K\r\n");
- }
- // ===== URI / string helpers =====
- function extractParam(uri, param) {
- var re = new RegExp("[?&]" + param + "=([^&]*)");
- var m = uri.match(re);
- return m ? m[1] : "";
- }
- function extractLabel(uri) {
- var m = uri.match(/otpauth:\/\/totp\/([^?]*)/);
- return m ? m[1] : "";
- }
- function urlDecode(str) {
- str = str.replace(/\+/g, " ");
- return str.replace(/%([0-9A-Fa-f]{2})/g, function(_, hex) {
- var c = parseInt(hex, 16);
- return (c < 128) ? String.fromCharCode(c) : ("%" + hex);
- });
- }
- function pad2(n) { return (n < 10 ? "0" : "") + n; }
- function padRight(s, n) {
- s = String(s);
- while (s.length < n) s += " ";
- return s.length > n ? s.substr(0, n) : s;
- }
- function padLeft(s, n) {
- while (s.length < n) s = " " + s;
- return s;
- }
- function spaces(n) { var s = ""; while (n-- > 0) s += " "; return s; }
- function repeat(s, n) { var r = ""; while (n-- > 0) r += s; return r; }
- // ===== Base32 decode =====
- function base32decode(str) {
- var alpha = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
- str = str.toUpperCase().replace(/=/g, "").replace(/\s/g, "");
- var bits = "";
- for (var i = 0; i < str.length; i++) {
- var v = alpha.indexOf(str.charAt(i));
- if (v < 0) continue;
- var b = v.toString(2);
- while (b.length < 5) b = "0" + b;
- bits += b;
- }
- var bytes = [];
- for (var i = 0; i + 8 <= bits.length; i += 8) bytes.push(parseInt(bits.substr(i, 8), 2));
- return bytes;
- }
- // ===== SHA-1 =====
- function rotl(x, n) { return (x << n) | (x >>> (32 - n)); }
- function sha1(data) {
- var h0 = 0x67452301, h1 = 0xEFCDAB89, h2 = 0x98BADCFE, h3 = 0x10325476, h4 = 0xC3D2E1F0;
- var len = data.length, bitLen = len * 8;
- data.push(0x80);
- while (data.length % 64 !== 56) data.push(0);
- data.push(0, 0, 0, 0, (bitLen >>> 24) & 0xFF, (bitLen >>> 16) & 0xFF, (bitLen >>> 8) & 0xFF, bitLen & 0xFF);
- for (var i = 0; i < data.length; i += 64) {
- var w = new Array(80);
- for (var j = 0; j < 16; j++)
- w[j] = (data[i + j * 4] << 24) | (data[i + j * 4 + 1] << 16) | (data[i + j * 4 + 2] << 8) | data[i + j * 4 + 3];
- for (var j = 16; j < 80; j++)
- w[j] = rotl(w[j - 3] ^ w[j - 8] ^ w[j - 14] ^ w[j - 16], 1);
- var a = h0, b = h1, c = h2, d = h3, e = h4;
- for (var j = 0; j < 80; j++) {
- var f, k;
- if (j < 20) { f = (b & c) | ((~b) & d); k = 0x5A827999; }
- else if (j < 40) { f = b ^ c ^ d; k = 0x6ED9EBA1; }
- else if (j < 60) { f = (b & c) | (b & d) | (c & d); k = 0x8F1BBCDC; }
- else { f = b ^ c ^ d; k = 0xCA62C1D6; }
- var t = (rotl(a, 5) + f + e + k + w[j]) | 0;
- e = d; d = c; c = rotl(b, 30); b = a; a = t;
- }
- h0 = (h0 + a) | 0; h1 = (h1 + b) | 0; h2 = (h2 + c) | 0; h3 = (h3 + d) | 0; h4 = (h4 + e) | 0;
- }
- return [
- (h0 >>> 24) & 0xFF, (h0 >>> 16) & 0xFF, (h0 >>> 8) & 0xFF, h0 & 0xFF,
- (h1 >>> 24) & 0xFF, (h1 >>> 16) & 0xFF, (h1 >>> 8) & 0xFF, h1 & 0xFF,
- (h2 >>> 24) & 0xFF, (h2 >>> 16) & 0xFF, (h2 >>> 8) & 0xFF, h2 & 0xFF,
- (h3 >>> 24) & 0xFF, (h3 >>> 16) & 0xFF, (h3 >>> 8) & 0xFF, h3 & 0xFF,
- (h4 >>> 24) & 0xFF, (h4 >>> 16) & 0xFF, (h4 >>> 8) & 0xFF, h4 & 0xFF
- ];
- }
- // ===== HMAC-SHA1 =====
- function hmacSha1(key, msg) {
- var bs = 64;
- if (key.length > bs) key = sha1(key.slice());
- var pk = key.slice();
- while (pk.length < bs) pk.push(0);
- var ipad = [], opad = [];
- for (var i = 0; i < bs; i++) { ipad.push(pk[i] ^ 0x36); opad.push(pk[i] ^ 0x5C); }
- return sha1(opad.concat(sha1(ipad.concat(msg))));
- }
- // ===== TOTP =====
- function computeTOTP(secret, counter, digits) {
- var key = base32decode(secret);
- var msg = [];
- for (var i = 7; i >= 0; i--) {
- if (i >= 4) msg.push(0);
- else msg.push((counter >>> (i * 8)) & 0xFF);
- }
- var h = hmacSha1(key, msg);
- var off = h[h.length - 1] & 0x0F;
- var code = ((h[off] & 0x7F) << 24) | ((h[off + 1] & 0xFF) << 16) | ((h[off + 2] & 0xFF) << 8) | (h[off + 3] & 0xFF);
- code %= Math.pow(10, digits);
- var s = code.toString();
- while (s.length < digits) s = "0" + s;
- return s;
- }
复制代码 |