基于ESP8266与CircuitPython的离线TOTP双因素认证器制作指南
1. 项目概述打造你的专属离线TOTP认证器在数字账户安全日益重要的今天双因素认证2FA几乎成了保护邮箱、社交账号乃至银行账户的标配。其中基于时间的一次性密码TOTP协议也就是我们熟知的Google Authenticator、Authy等应用所使用的标准因其开源、无需网络即可生成验证码的特性成为了最主流的选择。但你是否想过除了手机我们还能用什么来承载这些至关重要的动态密码对于像我这样不习惯随时携带手机或者需要在特定工作台比如实验室、工作室快速获取验证码的人来说一个独立的、专用的硬件设备会方便得多。这就是我动手制作这个基于CircuitPython与ESP8266的离线TOTP双因素认证设备的初衷。它本质上是一个微型的、联网的密码生成器。核心思路非常简单利用一块带有Wi-Fi功能的微控制器这里用的是Adafruit的Feather ESP8266在启动时通过网络时间协议NTP获取精确的全球时间然后结合预先烧录在设备里的“种子密钥”Secret Key按照TOTP标准算法在本地实时计算出6位数字的动态验证码并显示在一块小巧的OLED屏幕上。整个过程完全离线仅在启动时联网对时无需手机介入按一下复位键几秒钟内你常用的几个账户的验证码就清晰呈现在眼前。这个项目非常适合对物联网、嵌入式开发感兴趣的开发者或是追求极客生活方式、希望将数字生活的一部分“实体化”的朋友。它不仅仅是一个工具更是一个理解TOTP协议、CircuitPython编程以及嵌入式系统联网的绝佳实践。接下来我将从硬件选型、软件原理到每一步的实操细节完整地拆解这个项目的制作过程并分享我在调试过程中踩过的坑和总结的经验。2. 核心硬件选型与设计思路2.1 为什么是ESP8266和CircuitPython在开始焊接之前搞清楚为什么选择这些组件至关重要这决定了项目的可行性、易用性和最终体验。首先看主控Adafruit Feather HUZZAH ESP8266。选择它而非更简单的Arduino Uno或功能更强的ESP32是基于几个核心考量网络能力是刚需TOTP算法的核心输入之一是“当前时间”。要获得可靠且同步的全球时间最简便的方式就是通过Wi-Fi连接互联网访问NTP服务器。ESP8266内置了完整的Wi-Fi协议栈和TCP/IP协议栈使得网络编程变得像调用几个库函数一样简单完美满足了“联网对时”这个核心需求。内存与性能平衡TOTP计算涉及HMAC-SHA1哈希运算和Base32解码虽然不复杂但仍需一定的计算资源和内存RAM来运行算法和存储中间变量。ESP8266拥有约80KB的用户可用RAM和4MB的Flash足以流畅运行CircuitPython解释器以及我们的认证代码不会出现像在ATmega328PArduino Uno上那样捉襟见肘的情况。生态与兼容性Adafruit的Feather系列定义了统一的引脚排列和外形尺寸其ESP8266版本与众多“FeatherWing”扩展板完美兼容这为我们接下来添加显示模块提供了极大的便利。其次是编程语言CircuitPython。它是MicroPython的一个分支由Adafruit积极维护特别优化了对自家硬件产品的支持。选择它而非传统的Arduino C理由很充分开发效率极高无需编译、上传、重启的循环。通过串行REPL交互式解释器可以实时执行代码、调试变量修改代码后保存即可自动重新运行极大地加快了开发迭代速度。Python语法友好对于已经熟悉Python的开发者来说几乎没有学习成本。其丰富的内置库和清晰的语法使得实现TOTP这样的算法包含HMAC、字节操作等代码更简洁、更易读、更易维护。硬件抽象优秀通过board、digitalio、busio等模块操作GPIO、I2C等硬件接口的代码非常直观屏蔽了底层寄存器操作的复杂性。2.2 显示模块的选择与集成显示部分我选择了Adafruit FeatherWing OLED - 128x32。这块板子几乎是为Feather量身定做的即插即用物理引脚与Feather完全对齐直接堆叠“堆叠式”设计即可无需飞线极大简化了组装。驱动成熟Adafruit提供了官方的adafruit_ssd1306库在CircuitPython下开箱即用几行代码就能驱动显示。尺寸合适128x32的分辨率足以清晰显示3行文本每行约8个英文字符加一个6位验证码信息密度刚好满足显示多个账户的需求。关于组装原项目指南提到了一个非常巧妙的“平底焊接”技巧。通常我们会为Feather和OLED Wing都焊上排针然后用排母连接。但为了追求极致的轻薄和一体化你可以将OLED Wing的排针引脚直接对准Feather的焊盘轻轻压住并在背面焊接。这样处理后OLED Wing的背面与Feather的PCB背面几乎齐平形成一个坚固且超薄的“三明治”放在桌面上非常稳固也不易被线缆绊到。这是一个体现硬件DIY精神的细节。注意如果你选择直接焊接请务必确保引脚对准无误后再上锡。一旦焊错拆卸将非常困难可能损坏焊盘。对于初学者我仍然建议使用排针和排母虽然厚了几毫米但可逆性强方便调试和更换。3. 软件环境搭建与核心库部署硬件准备就绪后我们需要为ESP8266搭建CircuitPython开发环境。这一步是后续所有工作的基础。3.1 刷入CircuitPython固件ESP8266 Feather出厂通常运行AT指令固件或NodeMCU固件我们需要先将其替换为CircuitPython。下载固件访问CircuitPython官网找到“Adafruit Feather HUZZAH ESP8266”对应的最新.bin文件。务必确认版本号在2.2或以上因为早期版本可能缺少我们需要的网络库或存在稳定性问题。进入下载模式ESP8266通过串口进行固件烧录。你需要一个USB转串口工具如果电脑没有原生串口。连接方式如下Feather上的TX- USB串口工具的RXFeather上的RX- USB串口工具的TXGND-GND按住Feather上的GPIO0按钮或将其接地然后按一下RESET按钮再松开GPIO0。此时ESP8266进入Bootloader模式等待接收新固件。使用烧录工具在电脑上使用esptool.py这个Python工具进行烧录。命令大致如下请根据你的串口端口和固件路径修改esptool.py --port COM3 erase_flash esptool.py --port COM3 --baud 460800 write_flash --flash_sizedetect 0 adafruit-circuitpython-feather_huzzah-版本号.bin第一条命令擦除原有固件第二条命令写入新的CircuitPython固件。看到“Hash of data verified”和“Leaving...”即表示成功。验证烧录完成后断开GPIO0的接地按复位键重启。此时通过串口工具如PuTTY、Screen或Arduino IDE的串口监视器以115200波特率连接你应该能看到CircuitPython的启动提示符。如果看到的是乱码或AT指令响应说明固件没有正确启动需要检查烧录步骤。3.2 安装ampy并上传引导文件ESP8266版本的CircuitPython在连接USB后不会像一些ARM芯片如SAMD21那样自动挂载为一个U盘。因此我们不能直接拖放文件而需要使用Adafruit提供的ampy工具进行文件管理。安装ampy在电脑的命令行中使用pip安装pip install adafruit-ampy创建并上传boot.py这个文件在CircuitPython启动时最早运行。我们需要用它来关闭ESP8266的系统调试输出否则串口会被大量的系统日志刷屏干扰我们自己的打印信息。创建一个文本文件命名为boot.py内容仅两行import esp esp.osdebug(None)保存后使用ampy上传到ESP8266的根目录ampy --port COM3 put boot.py上传后按一下Feather的复位键使其生效。之后通过串口监视器连接应该只会看到干净的用户程序输出。3.3 部署必要的库文件我们的项目需要两个关键的库来驱动OLED屏幕adafruit_ssd1306OLED驱动和adafruit_bus_device底层总线抽象。它们需要被放置在ESP8266文件系统的/lib目录下。创建lib目录ampy --port COM3 mkdir /lib下载库文件包从Adafruit的GitHub Releases页面下载对应你CircuitPython版本号的“CircuitPython Library Bundle”。解压后在lib文件夹中找到adafruit_ssd1306.mpy(.mpy是预编译的字节码文件节省空间)整个adafruit_bus_device文件夹整个adafruit_register文件夹ssd1306驱动依赖它上传库文件使用ampy分别上传这些文件到ESP8266的/lib目录。注意上传文件夹时需要遍历其内部文件。例如ampy --port COM3 put adafruit_ssd1306.mpy /lib/adafruit_ssd1306.mpy ampy --port COM3 put adafruit_bus_device /lib/ # 对于adafruit_register可能需要进入其目录将其内部文件上传到/lib/adafruit_register/验证使用ampy --port COM3 ls /lib命令列出目录确认文件都已就位。4. TOTP核心算法解析与代码实现这是项目的灵魂所在。我们将在ESP8266上用Python实现完整的TOTP生成流程。理解每一行代码背后的原理不仅能帮你调试更能让你在需要定制时游刃有余。4.1 TOTP协议原理简述TOTP是RFC 6238定义的标准。其核心公式可以简化为TOTP Truncate(HMAC-SHA1(K, T))其中K是用户和服务端共享的密钥即我们常说的“种子密钥”Secret Key它是一个Base32编码的字符串。T是一个基于当前时间的时间戳计数器计算方式为T floor(当前Unix时间戳 / 时间步长)。标准时间步长是30秒。floor是向下取整这意味着每30秒T的值变化一次从而生成一个新的OTP。HMAC-SHA1是一种带密钥的哈希消息认证码算法用于生成一个20字节的哈希值。Truncate是一个动态截断函数从20字节的HMAC结果中提取出4字节的动态二进制码再将其转换为一个指定长度通常是6位的十进制数字。我们的代码需要精确复现这个过程。4.2 代码逐模块详解让我们结合项目的主代码code.py分解关键部分1. 导入与配置import time import adafruit_ssd1306 import bitbangio as io # 软件I2C硬件I2C在ESP8266上可能不稳定 import board import network import ntptime import ubinascii import uhashlibbitbangio因为ESP8266的硬件I2C在特定引脚和频率下可能有问题使用软件模拟bit-bang的I2C更可靠。network和ntptime用于Wi-Fi连接和NTP时间同步。uhashlibCircuitPython版的哈希库提供了SHA1算法。2. HMAC-SHA1的实现由于标准库的hmac模块可能因空间限制未被包含我们需要手动实现HMAC-SHA1。这是算法中最关键的一环。def HMAC(k, m): SHA1_BLOCK_SIZE 64 # 密钥补位 KEY_BLOCK k (b\0 * (SHA1_BLOCK_SIZE - len(k))) # 计算 inner 和 outer pad KEY_INNER bytes((x ^ 0x36) for x in KEY_BLOCK) KEY_OUTER bytes((x ^ 0x5C) for x in KEY_BLOCK) # 计算 inner hash 和 final hash inner_message KEY_INNER m outer_message KEY_OUTER SHA1(inner_message).digest() return SHA1(outer_message)这段代码严格遵循了HMAC的定义。理解0x36和0x5C这两个魔数ipad和opad是HMAC标准的一部分即可。SHA1()函数调用返回的是一个哈希对象需要.digest()方法才能得到字节串。3. Base32解码服务端提供的种子密钥是Base32编码的字母A-Z和数字2-7但HMAC运算需要原始的字节串Key。因此我们需要一个解码器。def base32_decode(encoded): # 补足等号 # 将字符串按8字符分块 # 每5位Base32字符转换为8位二进制字节 ...这个函数逐字符处理将5位一组的Base32值拼接起来每凑够8位就输出一个字节。代码中的位操作(bitbuff 5) | n和bitbuff bits是核心。这里有一个极易出错的点Base32字母表里没有数字0和1这是为了避免与字母O、I、L混淆。如果你在密钥中看到类似0或1的字符那一定是字母。4. OTP生成函数这是将时间戳和密钥结合最终产出6位数字的函数。def generate_otp(int_input, secret_key, digits6): # 1. 将密钥解码为字节 key_bytes bytes(base32_decode(secret_key)) # 2. 将时间计数器转换为8字节的大端序字节串 msg int_to_bytestring(int_input) # 3. 计算HMAC-SHA1 hmac_hash bytearray(HMAC(key_bytes, msg).digest()) # 4. 动态截断 offset hmac_hash[-1] 0xf code ((hmac_hash[offset] 0x7f) 24 | (hmac_hash[offset 1] 0xff) 16 | (hmac_hash[offset 2] 0xff) 8 | (hmac_hash[offset 3] 0xff)) # 5. 取模并格式化为6位字符串 str_code str(code % 10 ** digits) return str_code.rjust(digits, 0)int_input就是前面说的T即floor(Unix时间戳 / 30)。动态截断取HMAC结果的最后一个字节的低4位作为偏移量offset然后从offset位置开始取连续的4个字节组合成一个31位的大整数。最后对这个31位数取10^6的模得到0-999999之间的数并格式化为6位字符串前面补零。5. 主循环逻辑主程序的工作流清晰初始化显示显示欢迎界面。网络连接连接预设的Wi-Fi。NTP对时获取当前UTC时间。这里有一个关键处理ntptime模块返回的是“2000年1月1日”以来的秒数而Unix时间戳是“1970年1月1日”以来的秒数。两者相差946684800秒需要加上这个差值。时间同步与计算由于ESP8266没有硬件RTC断电后时间会丢失。我们采用“基准时间单调时间”的策略。在获取到NTP时间t已转换为Unix时间戳的瞬间记录下此时的time.monotonic()值mono_time。之后任何时刻的“当前Unix时间”都可以通过t - mono_time 当前time.monotonic()计算得出。time.monotonic()是单片机启动后经过的秒数不会复位非常适合用于测量时间间隔。循环显示在设定的显示时间内ON_SECONDS每秒计算多次当前时间对应的T值为totp列表中的每一个密钥生成OTP并刷新OLED显示。屏幕底部还会绘制一个随时间减少的进度条直观显示当前30秒周期的剩余时间。5. 实战配置注入你的密钥与连接网络代码框架有了现在需要将它变成属于你个人的认证器。这一步涉及敏感信息操作务必谨慎。5.1 修改Wi-Fi配置在code.py文件中找到这两行ssid my_wifi_ssid password my_wifi_password将它们替换成你家的Wi-Fi名称和密码。注意密码可能包含特殊字符在Python字符串中要正确转义。为了安全切勿将包含真实密码的代码上传到公开的代码仓库。5.2 获取并配置TOTP种子密钥这是最关键且需要耐心的一步。你需要从你启用2FA的网站或应用后台获取那个Base32编码的种子密钥。寻找密钥以Google账户为例在“两步验证”设置中选择“身份验证器应用”它会显示一个二维码。通常旁边会有一个“无法扫描”的链接点击后就会显示一串字母数字组合如JBSWY3DPEHPK3PXP这就是你的Base32密钥。其他服务如GitHub、Discord、Microsoft等流程类似都在2FA设置中寻找“手动输入”或“显示密钥”的选项。格式化密钥复制这串字符移除中间的所有空格或连字符确保它是一个连续的字符串。例如JBS WY3 DPE HPK 3PXP需要处理成JBSWY3DPEHPK3PXP。编辑代码找到code.py中的totp列表变量。它是一个Python列表里面包含了多个元组每个元组格式为(显示名称, 种子密钥)。totp [(Discord , JBSWY3DPEHPK3PXP), (Gmail , abcdefghijklmnopqrstuvwxyz234567), (Accounts, asfdkwefoaiwejfa323nfjkl)]将示例的元组替换成你自己的。例如(Google , JBSWY3DPEHPK3PXP)。显示名称你可以自定义比如用GitHub 、AWS 。我习惯用空格将名称右对齐这样显示时验证码能纵向对齐更美观。种子密钥粘贴你处理好的连续Base32字符串。安全警告此密钥是您账户二次验证的根密钥。一旦泄露他人即可生成与你同步的动态密码。因此这个code.py文件务必妥善保管仅在可信的离线环境中编辑并确保最终烧录到设备后不会以明文形式存储在其他地方。本项目为学习演示密钥存储在Flash中请勿用于保护极高安全等级的账户。5.3 首次测试与验证在将代码正式上传前强烈建议先进行本地测试。临时运行使用ampy的run命令直接在ESP8266上执行你修改好的code.py文件而不写入Flash。ampy --port COM3 run code.py观察输出打开串口监视器如PuTTY你将看到程序输出的日志连接Wi-Fi、获取IP、NTP对时、计算并打印出每个账户的OTP。交叉验证同时打开你手机上的Authenticator应用如Google Authenticator、Authy、Microsoft Authenticator等。比较设备串口打印的OTP与手机App上同一账户在同一时刻显示的OTP是否完全一致。测试PyOTP示例代码中默认的(Discord , JBSWY3DPEHPK3PXP)是一个公开的测试密钥。你可以先用这个密钥测试用手机扫描项目指南中提供的二维码确认设备生成的代码与手机App匹配这能验证你的算法实现和时钟同步基本正确。实操心得首次测试时很可能出现OTP不匹配的情况。99%的原因在于时间不同步。请确保你的ESP8266成功连接了互联网并且NTP服务器可访问默认pool.ntp.org在国内通常可用若不行可尝试ntp.aliyun.com或time.windows.com。你的手机和ESP8266的时间都相对准确自动从网络获取时间。即使有几秒的误差由于TOTP以30秒为周期也可能导致生成的代码落在不同的时间窗内。如果始终差一个周期30秒请检查代码中unix_time // 30这部分计算T值的逻辑以及NTP时间到Unix时间戳的转换EPOCH_DELTA是否正确。6. 最终部署、优化与问题排查测试无误后就可以进行最终部署并考虑一些优化和日常使用技巧。6.1 上传代码与设置自启动上传最终版code.py使用ampy的put命令将修改好的code.py文件上传到ESP8266的根目录这会覆盖之前的文件如果有的话。ampy --port COM3 put code.py理解自启动在CircuitPython中根目录下的code.py或main.py文件会在设备启动时自动运行。上传后每次给Feather上电或按复位键它都会自动执行我们的TOTP生成程序。调整运行参数在code.py开头部分有两个重要的配置变量ALWAYS_ON False # 设为True则屏幕常亮False则显示一段时间后息屏 ON_SECONDS 60 # 在非常亮模式下屏幕保持点亮的时间秒对于电池供电的场景建议设置ALWAYS_ON False并合理设置ON_SECONDS如30秒以节省电量。如果设备一直插着USB供电可以设为True方便随时查看。6.2 常见问题与排查实录在制作和调试过程中我遇到了几个典型问题这里汇总成排查清单问题现象可能原因排查步骤与解决方案串口无输出/无法连接1. 串口端口号错误。2. 波特率不对CircuitPython REPL默认115200。3. 驱动未安装CP210x或CH340。4.boot.py未生效系统调试日志刷屏。1. 在设备管理器中确认COM口。2. 确保串口工具波特率设为115200。3. 安装对应的USB转串口芯片驱动。4. 确认boot.py已上传并包含esp.osdebug(None)。无法连接Wi-Fi1. SSID或密码错误含大小写、特殊字符。2. 路由器设置了MAC过滤或隐藏SSID。3. 网络信号太弱。1. 仔细检查代码中的字符串特别是密码中的引号和转义。2. 检查路由器设置或将设备MAC地址加入白名单。3. 将设备靠近路由器测试。可在代码中sta_if.connect()后增加打印状态。NTP时间获取失败1. 未成功连接Wi-Fi。2. NTP服务器被屏蔽或网络不通。3. 本地防火墙/路由器设置阻止NTP端口123。1. 先确保Wi-Fi连接成功打印出IP。2. 尝试在代码中更换NTP服务器如ntp.ntsc.ac.cn国家授时中心。3. 检查网络环境或尝试在手机热点下测试。OTP与手机不一致1.时间不同步最常见。2. 种子密钥Secret Key输入错误。3. Base32解码函数有误。4. HMAC或动态截断算法实现有误。1. 对比设备串口打印的“Unix time”与在线时间戳网站如time.is的差异应在数秒内。2. 使用代码中自带的PyOTP测试密钥Discord进行验证排除密钥错误。3. 打开代码中的TEST True开关运行SHA1、HMAC、Base32的单元测试与预期值比对。4. 逐步调试打印出T值、解码后的密钥字节、HMAC结果等中间变量。OLED屏幕不显示或花屏1. I2C引脚接错SCL接SCLSDA接SDA。2. 供电不足。3. 库文件未正确上传或版本不匹配。4. 软件I2C初始化失败。1. 确认Feather与OLED Wing的堆叠方向正确引脚对齐。2. 确保使用稳定的5V USB供电。3. 使用ampy ls /lib确认库文件存在且完整。4. 尝试在代码中调整I2C初始化如i2c io.I2C(board.SCL, board.SDA, frequency400000)。程序运行一段时间后停止1. 内存泄漏在循环中不断创建对象未释放。2. 网络异常导致程序阻塞。3. 看门狗超时如果代码长时间阻塞。1. 检查代码确保在循环中没有持续创建大的对象如字符串拼接。尽量复用对象。2. 为网络操作如ntptime.time()添加超时和异常重试机制避免无限等待。3. 在长时间循环中适当加入time.sleep(0.01)或调用micropython.mem_info()监控内存。6.3 安全考量与使用建议在项目开头的FAQ里那个“咆哮体”评论虽然夸张但指出了一个严肃的问题种子密钥以明文形式存储在微控制器的Flash中。这是一个需要明确认知的安全边界风险任何能物理接触到这台设备的人如果具备一定的技术能力理论上可以通过读取Flash或串口调试等方式提取出密钥。项目定位本项目主要是一个教育演示和便捷工具适用于家庭、个人工作室等受控物理环境用于保护那些重要但非极度敏感的个人账户如社交媒体、某些论坛。它解决了“无手机时快速获取2FA码”的便利性问题。绝对不适用于保护企业服务器、银行主账户、加密货币钱包等涉及重大资产或安全责任的场景。对于这些场景请务必使用专用的、经过强安全认证的硬件安全密钥如YubiKey或完全离线的、有物理防护的生成器。给你的使用建议物理保管将制作好的设备放在安全、私人的地方如同你的家门钥匙。密钥管理用于生成设备密钥的原始code.py文件在烧录完成后应从开发电脑上彻底删除或加密存储。不要在多个设备间复制同一份密钥代码。定期检查像检查烟雾报警器一样偶尔验证一下设备生成的代码是否仍与手机同步以防时钟漂移过大。作为备份最好将它作为你手机认证器的一个备份而不是唯一依赖。这样即使手机丢失你仍有办法登录账户进行恢复操作。最后关于功耗如果你希望它更便携可以考虑为其配备一块小容量的锂电池如350mAh。在ALWAYS_ON False且ON_SECONDS设置较短如15秒的情况下待机电流可以降到毫安级别续航时间会相当可观。你可以通过测量不同状态下的电流来估算电池寿命。这又是一个可以深入探索的硬件优化方向了。

相关新闻

最新新闻

日新闻

周新闻

月新闻