iOS VPN 描述文件:存的是 passwordReference,不是密码
钥匙串引用只是句柄,不是密钥本身。传字节会引发 -50 错误、重启后无法连接,以及已保存描述文件中的轮换陷阱。
你安装描述文件,隧道建立,一切看起来都完成了。然后手机重启,on-demand 在没有前台应用的情况下唤醒连接,隧道却拒绝启动。描述文件还在,密码也没错,所以这个故障总把人引向错误的方向。Network Extension 无法从描述文件中读取你的密钥:它需要的是一条能自行解析的钥匙串引用。
下面的说明沿着这次交接走完一次连接尝试,从构造引用的查询一直到检验它的重启。
描述文件存的是句柄,不是密钥本身
NEVPNProtocol 之所以暴露 passwordReference 和 sharedSecretReference,是有原因的。守护进程启动隧道时,它可能并不运行在你的进程里,因此无法调用你的代码索要密码。它读取一条持久化的钥匙串引用,在自己的上下文中解析,并自行取出条目。
你通过向钥匙串索取来构造这条引用,这与索取数据是不同的请求:
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "vpn-credentials",
kSecAttrAccount as String: "user",
kSecReturnPersistentRef as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]把返回的对象交给协议,而不是条目的数据。实践中描述文件记录的是密钥放在哪里,这也是配置备份不会悄悄把密码复制进 plist 的原因。
API 为什么返回 -50
errSecParam 表示请求或值对该操作而言格式不对,而在引用请求与数据请求混淆时,最常看到的就是它。在接收方代码期待引用时索取数据、把原始字节赋给 passwordReference,或用应用无法使用的访问组去查询,都会产生它。
封装库会诱发这种做法。返回字节的辅助方法与返回引用的辅助方法不能互换,所以修复通常要换一个查询,而不是换一个密码。
同样的错配也解释了那种保存时毫无怨言、之后却连不上的描述文件:存进去的是一块数据,不是句柄,守护进程没有任何东西可解析。
保存正常,连接时失败
保存使用的是你应用的上下文。连接使用的是守护进程的上下文,两者的访问权限并不相同。因此描述文件可以通过模拟器里的全部测试,却在第一次 on-demand 启动时失败。
重启症状的形状也是如此:应用开着时能用,系统自己启动隧道时就死掉。
发布前有意识地测试后台路径。在应用关闭时触发 on-demand 连接,然后读设备控制台而不是应用日志,因为失败是由尝试解析引用的那个进程记录的。如果控制台显示守护进程拒绝该条目,说明引用没问题,问题在条目的可访问性。
重启能否成功由可访问性决定
条目必须能被启动隧道的任何系统进程读取,而这比应用在前台时的读者范围更广。这个约束替你选定了可访问性属性。保护到首次解锁的条目,在设备解锁过一次之后可用于后台启动;要求设备此刻处于解锁状态的条目则不行。
选择更严格的等级很诱人,而恰恰是它让锁屏手机上的 on-demand 描述文件失效。
改变等级之后,应重建条目而不是在现有条目上修改属性,然后用新的引用重新保存协议。对现有条目改属性并不总能产生文档所暗示的行为,而重建条目可以消除疑虑。
轮换正是已保存描述文件腐烂的地方
在钥匙串里改密码,并不会自动改变已保存描述文件所指向的对象。删除条目更糟:描述文件仍保留着指向已不存在之物的引用,而故障看起来像认证被拒。
按一个顺序轮换。更新条目,查询新的持久化引用,重新保存协议,然后在应用关闭的状态下连接,以证明守护进程仍能解析它。把用户密码与共享密钥放在不同条目里,让各自按自己的节奏轮换,并且永远不要记录查询字典或引用本身。句柄不是密钥,但它是一张通往密钥的地图。