我们今天登上了 Product Hunt! 留下评论

Authenticator 1.16.0 已发布。 查看更新内容

工作原理

面向安全审阅者的技术说明。这里的每一条都可以对照源码核实——包括那些对我们不太有利的部分,我们选择写出来而不是略过。

架构

本扩展是一个 Manifest V3 的 Chrome 扩展,使用 OTPAuth 在本地生成 TOTP 验证码。生成验证码、保存账户以及对账户加密都不涉及任何后端,也没有用户账号。我们只为免费版和 Pro 运营一台服务器 api.authenticator.sh(详见下文),它从不接收任何密钥、账户名称、服务名称或验证码。

这台服务器不持有任何人账户的密钥材料:没有密钥托管,也没有由我们掌控的找回途径。如果用户忘记密码又丢失了恢复码,我们无能为力——这是设计使然。

加密

密码保护是可选项,默认关闭。开启后:

加密算法
AES-256-GCM,每条记录、每次重写都使用随机 96 位 IV
密钥派生
PBKDF2-HMAC-SHA256,600,000 次迭代,128 位随机盐(遵循 OWASP 建议)
主密钥
256 位,由 crypto.getRandomValues 一次性生成;密码只用于包装它
指纹
在 HKDF 派生的子密钥下使用 HMAC-SHA256,用于跨设备合并记录
解锁密钥的存放位置
chrome.storage.session——仅存于内存,从不写入磁盘,浏览器关闭即清除,内容脚本无法触及
自动锁定
每次打开时/闲置 5、15、60 分钟后/直到浏览器关闭
实现方式
只用 WebCrypto。没有任何自行实现的密码学原语,也不打包任何加密库。

为什么密钥分两层

数据从不直接用密码派生出的密钥来加密。记录由一个随机主密钥加密;PBKDF2 的输出只负责包装这个主密钥。由此带来两点结果,两点都很重要:

  • 更改密码只重写 32 字节,而不必把每条记录、每份备份、每个导出文件重新加密一遍。存储系统丢数据,往往正是发生在这种批量重写的时候。
  • 160 位的恢复码可以独立包装同一个主密钥,所以忘记密码仍有救。恢复码只显示一次,必须重新输入以确认,且每次使用后都会更换。在用户确认之前,不会向磁盘写入任何内容。

哪些加密,哪些不加密

开启密码保护后,除标识符和指纹外,整条账户记录都会被加密——服务名称也在其中,因此即便配置文件被窃取,也看不出用户在哪些服务上有账户。启用时,扩展会先加密记录,再通过解密比对来校验,确认无误后才删除所有明文副本:浏览器本地存储、同步存储,以及全部七份轮转备份快照。

直说无妨,因为这关系到你的评估:

  • 在密码保护关闭时,账户以未加密形式存储;若 Chrome 同步处于开启状态,还会通过用户自己的 Google 账号进行同步复制。这些数据永远不会到我们这里,但 Google 会持有。同步可以关闭,关闭时也会一并清除已经同步过去的内容。
  • 按站点记录的使用历史——哪个账户用在哪个域名上——保存在本地,在收集过程中并不受保险库保护。启用密码保护会删除它;也可以单独将其关闭。

威胁模型

可以防住

  • 浏览器配置文件目录被窃取或复制
  • 批量外传浏览器数据的信息窃取型恶意软件
  • 有人物理接触到无人看管的电脑
  • 账户记录经 Chrome 同步流向 Google
  • 备份文件丢失或被窃(受密码保护的导出文件)

防不住

  • 保险库处于解锁状态时,以用户身份运行的恶意软件
  • 在输入过程中截取密码的键盘记录器
  • 已被攻陷的浏览器或操作系统
  • 这些验证码所对应的第三方服务本身被攻破
  • 你自己选择写出的未加密导出 —— 普通 JSON、otpauth:// 链接或 CXF,任何打开文件的人都能读取

简而言之:它保护的是静态存储的数据。对于使用期间已经落入攻击者控制的设备,它无法防护,也做不到防护。

权限

清单文件声明了五项权限,且不含任何主机权限:

storage
账户与设置存于本地存储,解锁后的密钥存于会话存储,另有可选的同步副本
activeTab
读取当前标签页的主机名以突出显示匹配的账户;当用户从屏幕扫描二维码时截取该标签页画面
contextMenus
在右键菜单中添加「插入 2FA 验证码」这一项,且仅出现在 http(s) 页面的可编辑输入框上
scripting
注入唯一那个把验证码填进输入框的函数 —— 只针对一个标签页、一次调用,且只在该菜单项被选中之后
sidePanel
在选择该显示方式时于 Chrome 侧边栏中打开应用;它不授予对任何页面的访问权限

没有任何内容脚本,没有 tabs、cookies 或 webRequest 权限,也没有可供网页访问的资源。在你于某个页面上索要验证码之前,页面里不存在任何属于我们的东西;届时运行的代码,是在你自己那次点击所授予的权限下,只为那一个标签页、那一次调用而注入的,事后不留下任何东西。

摄像头

用摄像头扫描二维码时,运行的是在标签页中打开的扩展页面,而不是弹出窗口——弹出窗口一旦失去焦点就会被销毁,而权限提示恰恰会夺走焦点。这无需在清单文件中声明任何权限:它使用浏览器标准的摄像头授权提示,按扩展来源授予,可在网站设置中撤销,并且在用户打开扫描器之前绝不会请求。视频在本地解码,从不传输也不存储。

免费版、Pro 与每日通信

自 1.14.0 起,扩展每天会与 api.authenticator.sh 通信一次——在浏览器启动时,或在弹出窗口渲染完成之后进行,绝不会插在显示验证码之前。请求由一份封闭的字段清单构成,全部在 src/billing/sync.ts 中的一个函数里构建,并由一项测试确保它不超出这份清单:

iid
由本次安装生成的随机 ID。与 Google 账号、邮箱地址或浏览器均无关联;但用户购买 Pro 后,购买记录会把购买时所用的安装以及激活了该许可证密钥的任何安装,与付款所用的邮箱地址关联起来
ver, ui_lang, browser_lang
扩展版本、界面语言、浏览器语言
installed_at, legacy, legacy_src
本次安装出现的时间,以及它是否早于计费功能
accounts, groups
它保存了多少个账户、分在多少个分组里——只是数字,绝不包括是哪些账户或分组的名称
vault, sync, open_mode, view_mode, style, dark, os
密码保护和同步是否开启,以及扩展以何种方式打开,账户列表如何显示(标准、紧凑或隐藏验证码),还有使用哪种风格、浅色还是深色,以及操作系统(Windows、macOS、Linux、ChromeOS)
pid, lid, has_key, key_hint
本次安装当前持有的已签名策略和许可证、是否已持有许可证密钥,以及该密钥的最后四个字符
opens
自上次通信以来,扩展每天被打开的次数(最多为最近 14 天)——每天只是一个计数,不涉及查看了什么
events
排队待发的计费事件,取自一份封闭列表:达到了上限、付费墙被显示或关闭、选择了某个方案、尝试了结账或密钥

从不发送:密钥、账户名称、服务名称、验证码,或使用它们的网站。服务器在其边缘节点根据请求得出国家/地区,不保存 IP 地址。卸载页面会以 ?i= 的形式收到同一个安装 ID:它会告知服务器该安装已被卸载,并把可选的问卷回答与该安装关联起来。

为此保存在 chrome.storage.local 中的内容:安装 ID、最近一次收到的已签名策略和许可证、本次安装持有的许可证密钥(手动输入的,或在本安装上购买后收到的)、服务器收到之前的每日打开次数,以及排队中的事件——不含任何账户数据。同步开启时,该安装是否早于计费功能,以及这个许可证密钥,也会写入 Chrome 同步,以便用户的其他 Chrome 配置文件获取它们并自动开启 Pro;关闭同步会把两者从 Chrome 同步中删除,重新开启则会再写回去。

服务器返回什么,以及扩展如何对待它:

令牌
策略(免费版的账户数量上限)和许可证(Pro)都是以 ES256(ECDSA P-256)签名的 JWS
验证
使用 WebCrypto,依据编译进构建产物的公钥(src/billing/keys.ts)进行校验。Chrome Web Store 版本只信任生产密钥;若构建中带有任何其他密钥,构建检查会拒绝它
离线
上限和许可证都在本地、依据最近一次收到的已签名令牌进行检查。在收到已签名的策略之前,没有任何限制——服务器连不上,永远不会把任何人锁在门外
上限能做什么
阻止添加账户,仅此而已。它无法隐藏、锁定、删除或修改任何账户,也无法阻止验证码、导出、备份、恢复或同步
购买
在新标签页中打开 Stripe Checkout,由 Link 担任销售方(merchant of record)。买家的邮箱由 Stripe 传给我们,用于签发和找回许可证密钥;密钥以哈希形式保存,另存一份加密副本,以便丢失的密钥可以重新发送
许可证密钥
每日通信的响应只会把密钥本身发给购买 Pro 的那个安装,而且只发到它通过 key_hint 报告的四个字符与该密钥一致为止——因此之后购买的密钥会替换较早的密钥;手动输入过密钥的安装永远不会收到

这个上限不是 DRM:代码是开源的,任何分叉都可以把它去掉。绝不能发生的是:一个人获取或使用另一个人的许可证,或者让别人的安装受到限制——那属于漏洞,在我们安全政策的范围之内。

外发网络请求

有两类请求是扩展自行发出的:时钟检查和每日计费通信。下面其余各项都是用户点击之后才发生的。Manifest V3 禁止远程代码,而且这里没有任何一项会携带账户、密钥或生成的验证码。完整清单如下:

主机何时是否携带用户数据?
time.akamai.com
timeapi.io
cloudflare.com
时钟偏差检查,带缓存;若所有来源都联系不上,设置中会说明。时钟偏了 TOTP 就会失效。否
authenticator.sh安装时打开欢迎页;卸载时打开反馈页,其地址中带有安装 ID;从弹窗打开帮助页和支持页。仅卸载页面携带安装 ID;其余情况为否(仅普通 Web 服务器日志)
api.authenticator.sh每天一次计费通信;用户购买 Pro、输入密钥、恢复购买或管理订阅时,发出结账、许可证和客户门户相关的请求。上文列出的字段——绝无账户数据。只有在用户亲自输入时,才会携带许可证密钥或邮箱地址
checkout.stripe.com
billing.stripe.com
Stripe Checkout 和客户门户,在用户购买 Pro 或管理订阅时于新标签页中打开。买家在那里输入的内容。适用 Stripe 和 Link 各自的条款
chromewebstore.google.com商店详情页,只在点击时新开标签页:评分提示进入评价表单。否(在 Google 看来与访问任何一个商店页面无异)

字体、脚本、样式和图片都不会从第三方主机加载。渲染界面所需的一切都随扩展包一起提供。

验证我们发布的内容

以上内容都不必只凭信任。扩展是开源的,每个版本都可复现:

  1. 从 Chrome 应用商店下载已发布的 .crx 并解压
  2. 切换到对应的 git 标签
  3. 在 Node 20 LTS 上执行 npm ci && npm run build
  4. 把得到的 dist/ 与解压出来的包进行比对

每个 GitHub 版本都会随附构建产物 dist/ 中每个文件的 SHA-256,文件名为 SHA256SUMS-v<version>.txt,采用 sha256sum 格式,所以第 4 步只需运行一次 sha256sum -c。差异应当仅限于压缩包内的文件顺序,以及不同 Node 补丁版本下压缩输出中的空白字符。

运行时依赖刻意保持很少——OTPAuth、React、jsQR、Zustand、Framer Motion 和 Lucide——并由提交入库的 lockfile 固定版本。如有需要,可提供 CycloneDX 格式的 SBOM。

查看源码

我们没有的东西

我们是一家独立的小团队。如果下面任何一项是贵方流程中的硬性要求,与其评估进行到第三周才发现,不如现在就知道:

  • 没有 SOC 2、ISO 27001 或同类认证
  • 迄今未委托第三方进行渗透测试
  • 没有付费的漏洞赏金计划

我们能提供的是:完全公开的源码、可验证的构建、最小化的权限面,以及一台从一开始就不会收到你的密钥、账户名称或验证码的服务器。

联系我们

漏洞报告与安全问题请联系 security@authenticator.sh。我们的披露政策、适用范围、响应时限和安全港条款见安全政策页面;哪些数据会被保存、哪些会离开设备,则写在隐私政策中。

我们乐于填写书面的供应商评估问卷,也可以陪同安全团队一起走查代码。

凡与安全无关的事项——安装、账户找回、部署中的计费问题——请见支持页面。请不要把漏洞报告发到那里;那个邮箱不按机密方式处理。