SwiftiCloudShellby

凭证不能上云——Shellby 的双轨同步

Shellby 复盘:让 SSH 客户端多端同步,最硬的约束是「密码和私钥绝不能明文进云端」。

Shellby 是多端 SSH 客户端,你在 Mac 上配好的主机、分组、隧道规则,希望在 iPhone、iPad 上也能直接用。这就要同步。

但 SSH 客户端的同步有一条不能碰的红线:密码和私钥绝不能以明文形式进入任何云端存储。一个安全工具如果把用户的服务器凭证明文传上云,那它自己就是最大的漏洞。这条约束决定了整个同步不能用一套通道,得分成两轨。

为什么是两轨而不是一套

先看 iCloud 提供的两种存储:

  • CloudKit 私有库:容量大、能存结构化数据、能跟 SwiftData 无缝对接。但它是 Apple 托管的——数据在传输和静态时加密,可密钥握在 Apple 手里,理论上 Apple 能解密。用它存主机名、端口、分组,没问题;存密码和私钥,不行。
  • iCloud 钥匙串:端到端加密,Apple 也读不了。但它是为「小段机密」设计的,不适合存结构化配置。

于是方案自然分裂成两条轨:

  • 配置轨:主机、分组、身份的元数据(名称、类型、公钥、Keychain 引用键)、端口转发规则 → 走 SwiftData + CloudKit 私有库。
  • 凭证轨:密码、私钥本体 → 走 iCloud 钥匙串,端到端加密。

关键的不变量是:CloudKit 记录里永远只有引用键字符串和公钥,绝无一个字节的明文凭证

两轨怎么拼回一个能用的主机

配置轨里的 SSHHost 存的不是密码,而是一个引用键,形如 host.<uuid>.passwordIdentity 存的私钥字段也是 identity.<uuid>.privkey。这些引用键随配置轨同步过去。

凭证轨里,同名的 Keychain 条目(真正的密文本体)随 iCloud 钥匙串同步过去。

到了新设备,两边都到齐:配置说「这台主机的密码在 host.<uuid>.password」,钥匙串里正好有这个键对应的密文——拼起来就能直接连,不用重新输凭证。这套「配置存引用、凭证存本体、按键名对应」的间接层,是安全分层(配置入 SwiftData、凭证仅入 Keychain)的自然延伸,同步只是把它跨了设备。

两轨异步到达:配置先到,凭证没到

分两轨带来一个新的边界情况:两条轨的传播速度不一样。经常是配置轨先到——你在新 iPhone 上已经看到那台主机了,但凭证轨还没同步过来。

如果不处理,用户点连接,代码拿着一个引用键去钥匙串里查密码,查不到,然后用空密码去认证,服务器回一个语焉不详的「认证失败」。用户一脸懵:主机明明在,为什么连不上?

处理方式是显式报错:引用键非空、却在本机取不到密码时,直接抛「凭据尚未同步到本机:请确认两台设备的 iCloud 钥匙串都已开启」,而不是无声地用空密码去撞认证失败。把一个隐晦的底层错误,翻译成用户能看懂、能自己解决的提示。

迁移不能用一次性标记

开启同步时,本机上已有的那些「仅本设备」凭证需要迁移成「可同步」——把 Keychain 条目从 …ThisDeviceOnly 重写成带 kSecAttrSynchronizable 的可同步项。

第一版的直觉写法是:迁移一次,在 UserDefaults 里记个标记,以后不再迁。这个方案被否了,理由值得记下来。

问题在于:迁移那一刻,iCloud 钥匙串未必就绪。如果某次迁移跑的时候钥匙串还没初始化好、其实没真正上云,而你却已经落下了「已迁移」的标记——那这台设备就永远不会再重试,凭证永远同步不出去。一次性标记把「迁移成功」和「迁移执行过」画了等号,而这俩不是一回事。

改成的方案:开启同步时每次启动都做一次幂等的 best-effort 迁移,只扫描「本地未同步」的条目,逐个重写成可同步项。第一次迁移完,之后启动扫到的就是空集,是个廉价的 no-op。没有标记,也就没有「标记错了永不重试」的坑。

CloudKit 反过来约束了数据模型

用 CloudKit 镜像 SwiftData,得满足它的一堆约束,这些约束直接改变了建模方式:

  • 所有属性必须有默认值——于是所有枚举都以 xxxRaw: String 落库加计算属性桥接(authMethodRawkeyTypeRawstorageRaw),既满足默认值要求又稳定可迁移;
  • 所有关系必须可空且有反向关系——HostGroupchildren / hosts 都建成可选数组 [HostGroup]? = []
  • 不能用 @Attribute(.unique)
  • 有些引用干脆不做成 SwiftData 关系——PortForwardRule.hostID 用一个扁平的 UUID 引用,而不是建模型关系,避免关系爆炸和 CloudKit 的兼容麻烦。

云同步不是加在数据模型之上的一层,它会往下渗透,重塑你的模型定义。 想清楚要不要同步,最好在建表之前,而不是之后。

同步不能热切换,但失败要优雅降级

CloudKit 的绑定是在容器创建时就固定的——你没法在 App 运行中把同步从关切到开。所以整个开关做成「重启生效」:启动时读 UserDefaults 决定容器配置。

更重要的是失败处理。CloudKit 容器可能加载失败:用户没登录 iCloud、容器没配好、网络不通。这时绝不能让 App 崩溃。做法是 catch 住失败,如果用户开了同步就回退到本地 store 照常启动,状态标记为 localFallback,并记下原因。设置界面把状态显示出来——「已回退本地(CloudKit 不可用)」配橙色云图标,可用时配绿色勾。用 CloudKit 自己的错误文本(容器未找到 / 未授权 / 未登录),比 SwiftData 的报错直白得多。

原则是:离线可用是底线。同步是锦上添花,云端出任何问题,本地功能都得照常跑。

顺带一提:第三条隐藏轨

其实还有第三条轨。AI provider 的配置列表(不含 API Key)走的是 NSUbiquitousKeyValueStore(iCloud KVS),而 API Key 仍走可同步的 Keychain。

为什么又是一套?因为这份数据的特性不同:它小、不敏感、且不需要重启就能生效(KVS 没有 CloudKit 那种容器绑定约束)。同一个 App 里,主机配置走 CloudKit(要重启)、AI 配置走 KVS(即时生效),不是不统一,而是按每种数据的特性选最合适的同步通道——大而结构化的走 CloudKit,小机密走 E2E 钥匙串,小而不敏感的走 KVS。

小结

给一个安全工具做多端同步,学到的东西:

  • 先定红线,再选通道:凭证不能明文上云是硬约束,它直接排除了单通道方案,逼出双轨;
  • 间接层是同步的朋友:配置存引用键、凭证存本体、按键名对应——这层间接让「结构化配置」和「端到端机密」可以各走各的通道再拼回来;
  • 迁移别用一次性标记:区分「执行过」和「成功了」,用幂等的每次重试代替一次性标志;
  • 云同步会重塑数据模型:默认值、可空关系、去唯一约束、扁平引用,最好在建模前就想清楚;
  • 离线是底线:云端失败优雅降级到本地,并把真实状态让用户看懂。

一句话:同步的难点从来不在「怎么把数据传过去」,而在「哪些数据能传、以什么形式传、传不过去时怎么办」。

留言

  • 加载中…

留言先审后发,通过后公开显示;邮箱只有站主可见。