你安装描述文件,隧道建立,一切看起来都完成了。然后手机重启,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 描述文件失效。

改变等级之后,应重建条目而不是在现有条目上修改属性,然后用新的引用重新保存协议。对现有条目改属性并不总能产生文档所暗示的行为,而重建条目可以消除疑虑。

轮换正是已保存描述文件腐烂的地方

在钥匙串里改密码,并不会自动改变已保存描述文件所指向的对象。删除条目更糟:描述文件仍保留着指向已不存在之物的引用,而故障看起来像认证被拒。

按一个顺序轮换。更新条目,查询新的持久化引用,重新保存协议,然后在应用关闭的状态下连接,以证明守护进程仍能解析它。把用户密码与共享密钥放在不同条目里,让各自按自己的节奏轮换,并且永远不要记录查询字典或引用本身。句柄不是密钥,但它是一张通往密钥的地图。

相关文章