***
先说个具体的场景。
我在外面,手机连着流量,想给博客仓库推一个 commit。GitHub 弹出框来要六位验证码。
手机就在我手里。可是能算出这六位数字的东西,躺在我宿舍那台已经合上盖子的笔记本里——更准确地说,躺在那台笔记本上某个应用的一小块存储里,而那台笔记本没开机,也没带出来。
于是我做了一个所有人都做过、但每做一次都很荒诞的选择:改天再推。
防贼的锁,顺手把主人也一起锁在门外了。这事我忍了大概一年。
先说清楚我打算写给谁
动笔前我先把可能读到这里的人分了下类。一篇技术文如果不知道自己写给谁看,最后就会写成一页说明书。
- 第一种,被验证码烦到麻木的人。 每次登录都弹框,你的反应就是”又来”。你不关心哈希和 RFC,只想知道有没有更省事的东西。
- 第二种,用过云同步验证器的人。 你大概隐约知道,自己那串密钥其实躺在别人的服务器上;换手机靠的是别人的 App 还能装回来,备份走的是别人的网络。这种”隐约”平时不疼,丢手机那天会疼。
- 第三种,动手的人。 看到”本地、离线、不联网”这几个词眼睛会亮,想扒开源码看看是不是真的。
所以这篇的顺序是:先讲感受,再讲做法,最后讲我在里面踩的坑。你属于哪一类,可以自己挑着跳读,我不拦你。
这东西到底在干嘛:一分钟版
TOTP 本质上特别朴素。
服务器和你手机上的这个应用,在某个时刻共享了同一串种子。此后每 30 秒,两边各自拿「种子 + 当前时间」算一次哈希,从中取六位数字。只要两边的钟差不多准,算出来的数字必然一样。
由此推出三件事:
- 它不需要联网,两边各自算各自的就行;
- 它不需要任何服务器参与,所以也不存在”我的密钥在谁家”这种问题;
- 唯一需要保密的就是那串种子,它约等于你的账号钥匙。
反过来也成立:谁拿到那串种子,谁就是你。 这句话后面还要用一次。
为什么我没直接用现成的
用过一圈之后,我发现不舒服的地方都很具体:
Authy 这类云同步方案确实方便,但密钥在它的服务器上,同步走它的网络。它家的服务哪天改了、封了、要实名了,你手里那台手机就只是一台手机。
Google Authenticator 后来能迁移了,但是靠二维码一张一张扫过去,二十个账号就是二十次扫码,还得两块屏幕同时在场——换手机本来就只剩一块屏。
应用商店里那些国产验证器,要求联网、要通知权限、要一堆我看了就头疼的权限,有些打开还会先给我看三秒开屏广告。一个本该离线的东西,为什么要联网,我实在想不明白。
密码管理器倒是什么都有,但它们太重了:解锁主密码、同步库、偶尔还要跟浏览器插件打一架。我在只想看六位数字的时候,不想先跟一个保险柜交涉。
我自己需要的其实特别小:
- 打开就是列表,左边名字右边数字;
- 点一下复制;
- 知道自己还有几秒;
- 换手机能搬走。
就这么四件事。
它长这样
主页

左边是名字,右边是动态码,整张卡片点一下就是复制。最底下那条进度条是三十秒倒计时,颜色从青走到黄再走到粉,越靠近粉就越接近刷新——不用盯着数字算,余光扫一眼就行。
右上角一个加号,底下”备份/还原”四个字。就这样,整个应用没有第五个按钮。整页没有任何一个网络请求。
新增

粘一条 otpauth:// 链接进去,名称、发行方、位数、周期、算法全都自己落位,不用我一个个选。
底下那行是当场试算出来的动态码——它跟 GitHub 页面上显示的一不一样,一眼就知道种子有没有粘错。这比”保存成功”有用多了。
编辑

高级选项平时收起来,免得看着眼花。删除按钮只放在这一页——列表上永远不长”删除”,一来界面干净,二来避免误触,三来删东西之前总该先打开看看这是哪个账号。
备份

换手机、重装、刷机,靠的都是它。
藏起来的几个决定
关于冷启动。 我把界面上的东西削到不能再削,就为了按下去到看见数字之间不要有等待:纯色底直达主页,没有跳转、没有骨架屏、没有加载动画。开机速度是我对这个应用唯一的要求,其余都可以让路。
关于保存方式。 用的是明文存手机私有目录。这是我在”再快一点”和”再安全一点”之间做的取舍,我选快了,理由和代价后面一起说。
关于复制。 点整张卡片复制,不弹”复制成功”的提示条——因为我要立刻切到别的应用去粘贴,提示条反而挡路。粘完回来它自己会消失。
一个差点让整个项目白干的坑
这块必须单独拎出来说。
算 TOTP 要用到 HMAC,浏览器里就是 crypto.subtle.importKey。我写的时候很顺手地填了 hash: "SHA1"——毕竟算法的名字就叫 SHA1。
结果代码是一声不响地不出数字。
不报错、不崩溃、控制台干干净净,界面上老老实实显示六个 X。TypeScript 也拦不住,因为那个参数的类型是 AlgorithmIdentifier,字符串随便写,编译期完全合法。真正报错要等到运行时,而且只在真机上——我在浏览器里试的时候刚好没触发到那条路径。
查出来之后修起来只有三行:
const WEB_CRYPTO_HASH = {
SHA1: "SHA-1",
SHA256: "SHA-256",
SHA512: "SHA-512",
} as const;
对,规范里要的是带短横线的写法。"SHA1" 会直接抛 NotSupportedError,而这个错误在打包之后会安静地消失在一个没人看的角落。
这件事教给我的东西比 TOTP 本身重要:一个算法的正确性,不该用”看起来对不对”来判断。
于是我在打包前写了一个脚本,把 RFC 6238 附录 B 里的官方测试向量全抄进去——SHA1、SHA256、SHA512 三种算法,各自六组时间点,六位数和八位数、30 秒和 60 秒周期,一共二十组。跑完二十组全对,我才敢去编译那个 APK。
现在回头看,如果当时没写这个脚本,这个应用会以”界面完美、数字全错”的状态被发出去,而我们两个都会以为它能用。
想要的话
APK 在这里,三兆不到,已签名,直接装:
https://github.com/sideonkeibulllll/ZFA-in-mobilephone-ZFA-
两点老实交代:
Manifest 里那条 INTERNET 权限是 Capacitor 模板自带的,我代码里一行网络请求都没有。你要是介意,把那一行删掉自己编译一遍,它照样能跑——数字本来就不用联网算。
以及上面说的明文存储。它意味着:谁能解开你手机的锁屏,谁就拿到了你全部的种子。 换句话说,它跟你手机里那个保存着登录态的浏览器是一样脆弱的。我知道这件事,我仍然选了它,因为这台手机只有我碰,而慢一拍的解锁我每天要经历几十次。
但这是我的取舍,不是你的。你要是在意,就自己把存储那层换掉——代码不长,改起来不算费劲。
后记
做完之后我意识到,这件事从头到尾其实只有一个词:拿回来。
把密钥从别人的服务器上拿回来,把算号这件事从网络里拿回来,把一个本该属于兜里的东西,拿回到兜里。
现在我在外面,想推 commit 就推。手机拿出来,扫一眼,六位数字,复制,粘贴,搞定。
那台合着盖子的笔记本,终于可以安心躺着不动了。
感谢你的聆听。
