自制 U2F 安全密钥:STM32F103 从移植 CanoKey 到跑通 u2f-token 全记录


📝 本文由 DeepSeek(AI) 根据博主本人的项目工作记录整理总结,部分表述可能与实际略有出入,仅供学习参考。

一直想给 GitHub、Google 这些账号配个硬件安全密钥,正好手头有几块便宜的 STM32F103 核心板,就琢磨着能不能自己焊一块”物理 Passkey”出来。这一折腾就是好几天,踩了一串坑,最后总算是把 U2F 安全密钥跑通了。记录一下,给有同样想法的人省点弯路。

缘起:想把 STM32F103 变成 Passkey

一开始的目标很”野”:直接移植 CanoKey,把它做成支持 FIDO2/Passkey 的完整硬件密钥。CanoKey 官方支持的是 STM32L432,我想把它搬到更便宜的 F103 上。

结果第一道墙就撞得结结实实:C8T6(64KB Flash / 20KB RAM)根本装不下 Passkey。

本地实测编译全功能固件,Flash 要 185KB;哪怕把 openpgp/piv/oath 全砍掉、只留 FIDO + admin,还是有 144KB。差得不是一点半点,这不是靠优化能补上的差距。

Flash 大头都在哪?lfs.c 20KB、ctap.c 19KB、bignum.c 15KB、admin_vendor.c 14KB、ecp.c 14KB,再加 L4 的 HAL 约 45KB……光这些就顶爆了 64K。

结论很干脆:要在 F103 上做 Passkey,得换大容量片子(F103RCT6 256KB / RET6 512KB);只用手头的 C8T6/C6T6 的话,退而求其次做 U2F 才是正路。

转向 U2F:u2f-token 上板

Passkey(CTAP2)做不了,但 gl-sergei/u2f-token 这个现成的 U2F 固件,实测跑起来很轻松:

芯片 Flash RAM 结果
C8T6(64K/20K) ~31.3K ~10.8K 轻松
C6T6(32K/10K) ~28.3K ~9.5K 裁剪后塞下

C6T6 是 32KB Flash,u2f.bin 正好 32768 字节填满,代码区只剩几百字节余量,属于”极限压线”但不超。C8T6 就宽裕多了。

这个固件用的是 Chopstx 这个轻量级协程内核,编译也不需要 OpenSSL,默认用空 attestation 证书就能出固件。

接线与刷机

硬件接线其实不复杂,关键就几根线:

  • USB:PA11 = D−、PA12 = D+,其中 D+ 必须经 1.5kΩ 上拉到 3.3V(有的最小系统板板载了,裸片要自己加),否则主机不会枚举
  • SWD 刷机:PA13 = SWDIO、PA14 = SWCLK,接 ST-LINK
  • 晶振:必须焊 8MHz 晶振(HSE → PLL×9 = 72MHz,USB 还要靠分频得到 48MHz)
  • BOOT0 接 10kΩ 到 GND 保证正常启动

编译用 arm-none-eabi-gcc,在 u2f-token/src 下:

# Windows 下 Makefile 的 ln -s 建 board.h 会失败,先手工拷一份
Copy-Item -Force "..\chopstx\board\board-blue-pill.h" "board.h"
make TARGET=BLUE_PILL -j4      # C8T6
# 或 make TARGET=C6T6 -j4      # C6T6(裁剪版)

刷机走 ST-LINK + STM32CubeProgrammer:

STM32_Programmer_CLI -c port=SWD -w build/u2f.bin 0x08000000 -rst

插曲:第一次焊接,把板子焊废了一半

上面这段”接线”其实是理想状态,真实开局没那么顺利——我第一次自己动手焊,手法太菜,芯片引脚焊成了一大片连锡;急着救场,又拿钳子暴力拔排线,结果把焊盘和走线扯坏了一堆:Type-C 座只剩一个方向的数据脚有信号,板载 LED 那根走线也被挂断过。

Type-C 坏了,板子不能直接扔,有个绕过的土办法:直接把 D+/D− 飞线到 USB。也就是绕过板载 Type-C,把 PA12(D+)、PA11(D−) 直接引到一根 USB 2.0 线或座子上——D+ 记得串 1.5kΩ 上拉到 3.3V、GND 共地,就能先跑起来,先验证固件没问题再说。

后面还是老老实实把 Type-C 座重新补焊了一遍(重点补数据脚和外壳接地脚,去氧化加锡),正反两面才都恢复导通。补焊好之后才用回了 ST-LINK 刷机 + Type-C 的正常姿势,后面几个坑也大多是从这次焊接翻车一路带出来的。

大坑一:上电完全不枚举,元凶是 Type-C 座只有一面导通

刷完固件,插上 USB,设备管理器连”未知设备”都不弹,一片死寂。

先用 hotplug 模式(不停核、不复位)读寄存器,确认固件其实已经在正常跑了:时钟、PLL、USB 时钟、USBEN 全部正确,但 USB 核的 FNR 一直是 0——从来没收到过主机的 SOF。

这只有一种解释:主机侧根本没看到 D+ 的上拉。

排查过程中还差点误判成”引脚短地”:固件跑过后,即使把 USBEN 清 0,USB PHY 仍霸占着 PA11/PA12,把这两个脚当普通 GPIO 读会一直是 0,看着就像短路到地。必须再按住 USB 复位(USBRST=1)才能真正拿回引脚控制权。

排除完这些,最后问题锁定在板载的 Type-C 座:它只有一个插接方向的数据脚是通的! 把 USB 反插一下就立刻枚举成功,正插没反应——这正是上一节焊接翻车扯坏走线的后遗症,重新补焊座子后才恢复正常。

教训:F103 的”无反应 / 无法识别”,优先怀疑物理层——数据线、座子正反、D+ 的 1.5k 上拉,其次才是固件。

用户在场:三种模式的取舍

U2F 有个”用户在场(user presence)”的语义:认证前需要用户按一下确认。官方固件默认上电后给一个 10 秒窗口,窗口内”人在场”,之后就得按键确认。

但我们的最小系统板没有独立按键,只有 RESET。实测发现 RESET 键不能当确认键:浏览器 WebAuthn 会掉线重连所以”感觉没事”,但 Linux 下的 libfido2(pamu2fcfg/pam_u2f)不会在 USB 掉线后重连,按 RESET 直接 fido_err_tx。

于是折腾出三种模式,按需选择:

模式 在场方式 适用场景
A 上电 10s 上电/reset 后 10 秒内恒在场 先 reset 抢窗口,10s 内完成认证
B 按键 按一下 GPIO 脚,保持 10 秒 板子常插,认证时按一下(最正常)
C 永远在场 恒在场、无物理确认 免按键,但插着就能被用,不用要拔

模式 C 最省事但最不安全(token 插着就等于裸奔),模式 B 才是正经用法。

按键版:PB8–PB11 四个脚任意按下

模式 B 需要接一个轻触按键到空闲 GPIO。折腾过程中最坑的是:按键到底接哪个脚、几个脚都认的问题。

早期只做 PB11 一个脚,结果出现”Win 能用、Linux 不认”的迷之现象(其实固件与 OS 无关,多半是按键接错了脚)。最后干脆把 PB8/PB9/PB10/PB11 四个脚全开,任意一个按下都算”在场”。

这里有个细节:判定用的是下降沿(哪个脚”刚被拉低”才算按下),而不是”任一脚当前为低”。因为实测本板 PB10 被外部拉低,如果用”低电平即按下”的逻辑,固件会永远认为按着,反而把其他脚也废掉。

按键检测从原版的 EXTI 中断改成 100ms 轮询:按键是人的速度,100ms 足够,而且避开了 EXTI 线号/IRQ 号搞混、没人清 EXTI->PR 导致中断风暴这些坑。

大坑二:新 C8T6 上 NeuG 永久卡死

换了一块新的 C8T6,烧录后完全不枚举,而且 pbt_init 压根没执行。停核读 RAM 定位到:

  • 主线程卡在 random_init → neug_get 等随机数
  • RNG 线程卡在 adc_wait_completion 等 DMA
  • ADC1_SR=0、DMA 的 CNDTR 停在起始值不动 → ADC 根本没在转换

根因很有意思:Chopstx 的 adc-stm32f103.c 里故意定义了一个 DELIBARATELY_DO_IT_WRONG_START_STOP 宏,故意不遵守 ADON→SWSTART 的稳定时序来制造 ADC 噪声当熵源。旧的 C6T6/C8T6 上能跑,但这块克隆 C8T6 的 ADC 在这种”错误启动”下无法起振,导致随机数发生器(NeuG)饿死。

修法是把”错误启动”改成编译期可关,只对本 target 关掉。修完后 ADC 正常转换、RNG 正常出数,健康测试用原始阈值就能过。

这是典型的环境/批次差异 bug:代码没错,但换了块芯片就崩,排查起来特别隐蔽。hotplug 读 RAM 栈回溯是定位这类问题的利器。

大坑三:有些 USB 口”代码 10”

同一份固件,有些 USB 口能正常用,有些口报”该设备无法启动(代码 10)”。这已经是物理层/主机侧的问题了,按可能性排:

  1. USB 3.0 口对全速设备不友好:F103 是 USB Full-Speed(12M),在 AMD xHCI 的 USB3 口上常枚举失败。换 USB 2.0 黑口,或串个 2.0 hub
  2. 双电源顶牛:USB 5V 和 ST-LINK 3.3V 同时供电会让 3.3V 轨被两个源顶。只留一路电
  3. 上电时序竞态:D+ 上拉是硬连的,插 USB 瞬间主机就来枚举,而固件还在跑 NeuG(约 1s)、USB 控制器尚未使能,敏感的口这次枚举就失败了。先让板子跑起来再插数据线可缓解

安全篇:RNG 与”设备主密钥”

折腾固件之余,安全上最值得记的两点:

一是 RNG。 F103 没有硬件随机数发生器,Passkey/U2F 的私钥、随机数全靠软件熵源。CanoKey 的 random32() 默认是 weak 的弱 LCG(甚至没正确播种),必须自己采集噪声(ADC 抖动、未初始化 SRAM、USB SOF 抖动等)喂给 DRBG 覆盖它。这也是上面 NeuG 卡死时主线程跟着一起卡死的原因——随机数是整个安全链的地基。

二是设备主密钥(device key)。 U2F token 首次上电会随机生成一个设备主密钥,之后注册的每个凭据私钥都由它用 HMAC 派生。这个密钥是这台 token 的全部身份,丢了或重刷导致重新生成,之前注册的所有账号密钥就全部失效。

所以升级固件时有个关键操作:先把芯片里的设备主密钥和计数器读出来,注入到新固件的 bin 里再烧录,别直接全片擦除。备份文件更是要单独存、别提交进 git、别外传——它等于你的钥匙本体。

自签 attestation 证书也类似,用 OpenSSL 生成 EC P-256 私钥 + 自签证书后编进固件即可,私钥同样要保管好。

结语

从”想做 Passkey”到”认清现实、退而做 U2F”,再到解决 USB 枚举、用户在场、ADC 卡死一串问题,最后跑通一块能用来给 GitHub 做 2FA 的安全密钥,收获还挺大的。

几点总结:

  • 先算清资源:想做硬件密钥前,先搞清楚目标固件的体积需求,别像我一上来就选错了片
  • 物理层优先:USB 枚举失败,先查线、座、上拉,其次才是固件
  • 随机数是地基:没有可靠熵源的安全硬件是纸糊的
  • 设备主密钥是命根:备份、注入、保管,一步都不能马虎

目前这块板子的 WebAuthn 注册流程已经跑通,下一步是把 Linux 的 pam_u2f 登录也验证一遍。后面如果真要做 Passkey,估计得换 256KB 的 RCT6 重新上路了。


  目录