← 博客

给一个 WKWebView 应用补上密码自动填充

9 分钟

过去一年 Overlay 的反馈里出现最多的一句话,是各种版本的"为什么我又要输一遍密码"。说得对。Overlay 是一个装满网页应用的悬浮面板, 每个标签底下是一个 WKWebView,它给你 Safari 的内核,但不给你 Safari 的密码管理——那部分在 Safari 这个应用里,苹果没开放, iCloud 钥匙串里的网站密码也没有 API 能读(ASAuthorizationPasswordProvider 只管"通过 Apple 登录"那一条,我查过)。 所以 Overlay 里每个登录框都是个空白文本框,一直是。

这事我拖了很久,因为这个功能做实了就是一个密码管理器,而密码管理器是那种你不希望"差一点"的东西。 这篇写它最后做成了什么样,主要是给自己留个记录,免得以后忘了为什么这么做。

动手前先定下来的几件事

写代码之前我逼自己先回答几个问题,不然我知道自己做到一半会改主意。

存储:一个文件,AES-GCM 加密,256 位的密钥放登录钥匙串。不是数据保护钥匙串——那个我先试的,直接分发的构建没有描述文件根本用不了 (报 -34018,硬加 entitlement 进程直接被杀)。文件离了密钥没用,密钥离了文件也没用,本机密码库能给的保护大概就到这。

填充:绝不自动。点进登录框才出下拉,你不点某一行,页面里什么都不会被写进去。我知道 Chrome 在只有一条匹配时会在页面加载完就填上。 我不想为"凭据落进了用户根本没打算提交的表单"负责,所以每一次填充都得是一次点击或一次回车。

独立会话的标签——就是有自己 cookie 罐、能在一个站上当两个人的那种——和别的标签看同一个库。密码属于站点,不属于 cookie 罐。

还有,免费,不进 Pro。为"能登录"这件事收钱,我觉得不对。

再一条偏技术的:注入的 JavaScript 只描述页面。哪个框是用户名、表单是登录还是注册、登录成没成功,全在 Swift 里判,那边能写测试。 没法测的页面脚本我写过够多了,知道后面会怎样。

结构

一个 WKUserScript 在每个 frame 里跑,独立的内容世界,文档加载完执行。它遍历输入框,给每个编号, 把一列描述发回来:type、name、id、autocomplete、placeholder、label 文字、可见不可见、属于哪个 form。不带值。

值只在两处过桥。表单提交时,脚本把刚输入的密码发过来,因为那是能问"要不要保存"的时刻; 用户在下拉里选了一行,原生把密码发给脚本去填。就这两处,其他地方不碰机密。

原生侧给每个表单分类(登录、注册、改密、只有用户名、只有密码),用 frame 的 origin 去库里匹配。 origin 取的是 WKFrameInfo.securityOrigin,不看消息里的任何字段。页面想在消息体里写什么 URL 都行,但它伪造不了消息是从哪个 frame 来的。

往 React 的表单里填

我的第一版填充是设 input.value 再派发一个 input 事件。测试用的纯 HTML 表单都好,第一个真实站点就不行,那个站是 React 的。 React 记着自己上次设的值,绕过它 setter 的改动一律不认,它的状态里那个框还是空的。

最后在 React 18、Vue 3、原生表单上都通的做法:调原型上的 setter,HTMLInputElement.prototype.value, 然后派发 inputType: "insertText" 的 InputEvent,再跟一个 change。这就是键盘敲进去会产生的东西。 网上有个流行的手法是直接去改 React 的 value tracker,但那个 tracker 挂在页面世界的 wrapper 对象上,内容世界的脚本看不见,所以我没靠它。

一条密码属于哪个站

在 login.example.com 存的登录,到 app.example.com 应该能用;在 example.co.uk 存的,绝不能出现在 evil.co.uk。 划这条线的是 Public Suffix List,所以 Overlay 打了一份进去(ICANN 和私有两段,一万条左右的规则),在原生侧算可注册域。 我找过能绕开打包它的办法,没有。

匹配分三级:精确 origin,然后同 host,然后同可注册域,同级按最近使用排。有两条我不会放松:HTTPS 下存的密码不会出现在 HTTP 页面上; 端口必须一致。匹配来自别的子域时,下拉那一行右边用灰字标出那个 host,点之前看得见。

什么时候问"保存这个密码?"

提交那一刻问是最直接的想法,也是错的,因为错密码一样会被提交。我最后照抄了 Chrome 的判据:提交产生一个待定草稿; 接下来的导航完成后新页面上没有空的密码框,登录大概成功了,问;登录表单还在原地,大概失败了,草稿扔掉。 不导航的站点靠第二个信号——密码框从 DOM 里消失。三十秒两个都没等到,草稿过期。

这是个启发式,有洞。登录成功后仍然把登录表单留在页面上的站点,对 Overlay 来说就像失败,你永远等不到那张卡。 这个我没找到通用的解法,Chrome 也有同样的盲区。

多步登录,Google 那种第一页填邮箱、第二页填密码的,要多加一小段:只有用户名框的表单提交时,按可注册域把那个用户名记五分钟, 粘到后面那个只有密码的提交上。

库里已经有同 origin 同用户名、密码也一样的,什么都不显示,只把最近使用时间往后拨。密码不一样,卡片改问要不要更新。

下拉

Overlay 的面板是非激活窗口,浮在你的工作之上但不抢前台。下拉得守同样的规矩,所以它是一个无边框 NSPanel, 作为子窗口挂着,位置是拿字段的 CSS 矩形乘 pageZoom、经 webview 换到窗口、再换到屏幕坐标。

一个永远成不了 key 的窗口会坏掉两样你想当然的东西。SwiftUI 的 onHover 不触发,hover 高亮改用 AppKit 的 tracking area, 设成 activeAlways。键盘事件根本到不了,所以下拉开着时由注入脚本截获方向键、回车、Esc、Tab 再转发过来,页面看不到这些按键。 我还学到一件事:列表在不动的鼠标下面滚过去时,AppKit 会给滚到鼠标底下的那一行补一个 mouse-entered, 高亮被从键盘选中的位置拽走——后来改成鼠标没真动之前忽略 hover 才算完。

耗时最久的是苹果自己的登录。App Store Connect 把登录表单嵌在一个来自 idmsa.apple.com 的 iframe 里, 跨域 frame 里的脚本没法往上走到顶层文档去算自己在屏幕上的位置。我的第一版直接放弃,在页面顶部放一条建议栏,很丑, 而且用户指出来它没有 hover、离字段也远。解法来自另一头:在顶层文档里,焦点落在 iframe 里时 document.activeElement 就是那个 iframe 元素, 你读不到它的内容,但能读它的矩形。字段在 frame 里的位置加 iframe 在顶层的位置,就是精确的锚点。建议栏还留着当兜底,但之后我再没见过它。

Touch ID、剪贴板,以及没有导出

在管理窗口里显示或拷贝密码要过 Touch ID(没有的 Mac 用登录密码),过一次五分钟内不再烦你。 拷贝会打上"机密"标记,遵守这个约定的剪贴板管理器不会记录;六十秒内你没拷别的东西,剪贴板就清掉。

填充也可以要求同一道认证。带 Touch ID 的 Mac 默认开,没有的默认关,因为没有传感器时每填一次弹一次密码框没法用。 这个开关是我意识到"锁显示不锁填充"有个明显的洞之后加的:能坐在你已解锁的 Mac 前填一次密码的人,也能把密码框切成明文看一眼。

有从 Chrome、Edge、Safari、Firefox、Bitwarden、1Password 的 CSV 导入,导出我决定不做。一份明文 CSV 防不住已经能过你机器上 Touch ID 的人, 它只是在你用完之后留下一个装着全部密码的文件。备份靠 Time Machine,它会把钥匙串和文件一起恢复。以后如果做可携带的备份,会是带口令的加密包。

出过的问题

某天下午应用连续闪退六次。原因是一个清理例程在启动五秒后调 WKWebsiteDataStore.allDataStoreIdentifiers, 而那时进程还没建过任何 web view——WebKit 的主运行循环是那时候才建的。只在当前标签是设置页时发生。我之前每次测都开着一个网页。

微博的老登录表单把 value="邮箱/会员帐号/手机号" 当假占位符,聚焦才清空。我把这串字当用户名存了。 现在抓到的用户名只要和字段的 placeholder、label、aria-label 或默认值相同,就当作空。

下拉的早期版本每一行底下都写着"来自 api.weibo.com"。和我一起测的用户要域名靠右、灰一点、去掉"来自"两个字,还要 Chrome 那样的密码圆点。三条他都说对了。

还没有的

多台 Mac 之间不同步,库在本机,密钥也不会跟着走。注册表单上还不会生成密码。没有 passkey,WebKit 的 WebAuthn 没有以我能用的形式开放给嵌入方。 这几件都比这篇讲的东西小,前两件我会做。

面向使用者的版本在在 Overlay 里保存和填充密码。版本说明在更新日志。

相关