niko 加载中
0%

ZFA自建手机版-把验证码揣进兜里

一个全程不联网的手机验证码器,以及我为什么受不了现成的那些

作者天镜发布于技你太美技术随笔Android安全应用aigc辅助

***

先说个具体的场景。

我在外面,手机连着流量,想给博客仓库推一个 commit。GitHub 弹出框来要六位验证码。

手机就在我手里。可是能算出这六位数字的东西,躺在我宿舍那台已经合上盖子的笔记本里——更准确地说,躺在那台笔记本上某个应用的一小块存储里,而那台笔记本没开机,也没带出来。

于是我做了一个所有人都做过、但每做一次都很荒诞的选择:改天再推。

防贼的锁,顺手把主人也一起锁在门外了。这事我忍了大概一年。

先说清楚我打算写给谁

动笔前我先把可能读到这里的人分了下类。一篇技术文如果不知道自己写给谁看,最后就会写成一页说明书。

  • 第一种,被验证码烦到麻木的人。 每次登录都弹框,你的反应就是”又来”。你不关心哈希和 RFC,只想知道有没有更省事的东西。
  • 第二种,用过云同步验证器的人。 你大概隐约知道,自己那串密钥其实躺在别人的服务器上;换手机靠的是别人的 App 还能装回来,备份走的是别人的网络。这种”隐约”平时不疼,丢手机那天会疼。
  • 第三种,动手的人。 看到”本地、离线、不联网”这几个词眼睛会亮,想扒开源码看看是不是真的。

所以这篇的顺序是:先讲感受,再讲做法,最后讲我在里面踩的坑。你属于哪一类,可以自己挑着跳读,我不拦你。

这东西到底在干嘛:一分钟版

TOTP 本质上特别朴素。

服务器和你手机上的这个应用,在某个时刻共享了同一串种子。此后每 30 秒,两边各自拿「种子 + 当前时间」算一次哈希,从中取六位数字。只要两边的钟差不多准,算出来的数字必然一样。

由此推出三件事:

  • 它不需要联网,两边各自算各自的就行;
  • 它不需要任何服务器参与,所以也不存在”我的密钥在谁家”这种问题;
  • 唯一需要保密的就是那串种子,它约等于你的账号钥匙。

反过来也成立:谁拿到那串种子,谁就是你。 这句话后面还要用一次。

为什么我没直接用现成的

用过一圈之后,我发现不舒服的地方都很具体:

Authy 这类云同步方案确实方便,但密钥在它的服务器上,同步走它的网络。它家的服务哪天改了、封了、要实名了,你手里那台手机就只是一台手机。

Google Authenticator 后来能迁移了,但是靠二维码一张一张扫过去,二十个账号就是二十次扫码,还得两块屏幕同时在场——换手机本来就只剩一块屏。

应用商店里那些国产验证器,要求联网、要通知权限、要一堆我看了就头疼的权限,有些打开还会先给我看三秒开屏广告。一个本该离线的东西,为什么要联网,我实在想不明白。

密码管理器倒是什么都有,但它们太重了:解锁主密码、同步库、偶尔还要跟浏览器插件打一架。我在只想看六位数字的时候,不想先跟一个保险柜交涉。

我自己需要的其实特别小:

  • 打开就是列表,左边名字右边数字;
  • 点一下复制;
  • 知道自己还有几秒;
  • 换手机能搬走。

就这么四件事。

它长这样

主页

Z-F-A 主页令牌列表:左侧名称、右侧六位动态码,每张卡片底部有一条 30 秒倒计时进度条,右下角是编辑入口

左边是名字,右边是动态码,整张卡片点一下就是复制。最底下那条进度条是三十秒倒计时,颜色从青走到黄再走到粉,越靠近粉就越接近刷新——不用盯着数字算,余光扫一眼就行。

右上角一个加号,底下”备份/还原”四个字。就这样,整个应用没有第五个按钮。整页没有任何一个网络请求。

新增

新增令牌浮层:粘贴 otpauth 链接后自动拆出密钥、名称、发行方,并当场试算出一个动态码

粘一条 otpauth:// 链接进去,名称、发行方、位数、周期、算法全都自己落位,不用我一个个选。

底下那行是当场试算出来的动态码——它跟 GitHub 页面上显示的一不一样,一眼就知道种子有没有粘错。这比”保存成功”有用多了。

编辑

编辑令牌浮层:高级选项(位数、周期、算法)默认折叠,删除按钮只放在编辑页里

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

备份

备份还原浮层:导出一段 JSON 明文,也可以把备份粘贴回来导入,重复的会自动跳过

换手机、重装、刷机,靠的都是它。

藏起来的几个决定

关于冷启动。 我把界面上的东西削到不能再削,就为了按下去到看见数字之间不要有等待:纯色底直达主页,没有跳转、没有骨架屏、没有加载动画。开机速度是我对这个应用唯一的要求,其余都可以让路。

关于保存方式。 用的是明文存手机私有目录。这是我在”再快一点”和”再安全一点”之间做的取舍,我选快了,理由和代价后面一起说。

关于复制。 点整张卡片复制,不弹”复制成功”的提示条——因为我要立刻切到别的应用去粘贴,提示条反而挡路。粘完回来它自己会消失。

一个差点让整个项目白干的坑

这块必须单独拎出来说。

算 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 就推。手机拿出来,扫一眼,六位数字,复制,粘贴,搞定。

那台合着盖子的笔记本,终于可以安心躺着不动了。

感谢你的聆听。

ZFA自建手机版-把验证码揣进兜里

https://blog.8765777.xyz/posts/post-20260923-zfaa10/

作者天镜发布于许可协议CC BY-NC-SA 4.0
距今已经过去