Files
ShadowsocksX-NG/docs/research/server-configuration-encryption.md

27 KiB
Raw Permalink Blame History

服务器配置文件整体加密方案研究(2026-10-18)

研究日期:2026-10-18。本文记录调研时点的一手资料与代码快照,并提出候选方案;不表示项目已采纳或实现。后续实现应再次核实代码、签名条件及支持的 macOS 版本。

结论与推荐

推荐以 CryptoKit AES-256-GCM 对完整服务器库序列化结果加密,随机 256 位主密钥存于 Keychain。服务器名称、地址、端口、加密方法、密码、插件参数、分组、订阅 URL 和相关元数据进入同一认证加密 payload;Keychain 不再按服务器拆分保存这些秘密。AES.GCM 同时提供加密和认证,并支持认证附加数据(AAD);CryptoKit 是 Apple 推荐优先使用的高层密码学接口。AES.GCM、CryptoKit

对当前单个 JSON 文档,不建议增加数据库、SQLCipher、通用密钥管理系统或 KEK/DEK 双层封装。一个文档主密钥足够;用版本化文件头为后续格式迁移留边界。ChaCha20-Poly1305 同样合适,但当前没有互操作或性能证据要求选择它;AES-GCM 作为首选是本报告的工程判断,并非宣称另一算法不安全。Apple ChaChaPoly、RFC 8439

本次任务范围已明确:

  • 本次范围:持久服务器库整体加密。 密码、插件参数、订阅 URL 与服务器库其他资料一起进入密文;服务器库专用 Keychain 项只保存主密钥。监听认证、连通性检测 URL 等其他用途的凭据不自动纳入迁移。
  • 另案处理:运行时文件加密。 用户已明确 sslocal-active.json 的加密需要改造上游 sslocal,本次不研究或实施该改造,也不将运行契约及派生认证文件无明文作为本次验收条件。

考虑到开源项目可能更换签名 Team、用户也会自行编译,本项目优先推荐经典登录钥匙串 + 严格 ACL + 显式加密导出/恢复;换 Team 后可能需要前台重新授权,不能承诺静默访问。Apple 默认推荐的 data protection keychain 仍是候选,但其访问组身份与签名授权关联,不能仅凭相同 bundle ID 或用户弹窗读取旧 Team 的钥匙。详见第 4.3 节;算法和密文格式不因此改变。TN3137、Keychain access groups

1. 调研时点代码快照

以下以 HEAD 5fc5ddf 为调研快照,仅用于说明改造接缝,不作为持续维护的现状文档。GLOSSARY.md 已将用户管理文档与运行配置区分,且已有威胁模型不承诺抵御同用户进程被控制、root 或历史快照;本报告不自动扩张这些保证。

面 2026-10-18 核实结果(HEAD 5fc5ddf) 对加密的影响
服务器库 CatalogFileStore.swift 写 schema v6,读取兼容 v1–v6;JSON 包含整树 entries、rootChildren、subscriptions、favoriteIDs 应加密整个文档,而非只加密 ServerFields 中部分字段
服务器字段 CatalogModels.swift 定义服务器数据;秘密通过 credential reference 关联 最终需新增包含真实秘密的持久 payload,不能只把原有引用 JSON 加密后声称秘密已整合
现有 Keychain CredentialStore.swift 使用 Generic Password,service 为 com.qiuyuzhou.ShadowsocksX-NG2.credentials,值为 UTF-8 String;订阅 URL 也已存 Keychain 用户描述中的“仅密码和插件参数”需补上订阅 URL;新主密钥应使用二进制 Data 接口,避免沿用 String 秘密语义
Keychain 属性 同文件设置 AfterFirstUnlock,没有设置 kSecUseDataProtectionKeychain 不能根据属性名或注释推断真实 macOS 锁定语义,详见第 4 节
原子写 AtomicFileWriter.swift 使用 0700 目录、0600 同目录临时文件与替换;未实现 fsync 可复用权限与替换路径,但必须先在内存加密,再写临时文件;不能宣称已有断电持久性保证
加载失败 CatalogCommitCoordinator.swift 存在 (try? fileStore.load()) ?? CatalogDocument() 加密后不能把密钥不可用、认证失败或损坏当作空库,否则后续提交可能覆盖可恢复数据
激活 LeafPreparation.swift 读取凭据准备运行文档 秘密整合后需调整数据来源与凭据事务模型
运行时 RuntimePaths.swift 区分 GUI 私有 catalog.json 与运行契约 sslocal-active.json;SslocalRuntimeDocument.swift 可编码真实 password/pluginOpts 库文件加密不会自动覆盖运行契约
Agent AgentContract.swift 读取明文 Data,向 sslocal 传 -c <path>,并写监听认证派生文件 运行时契约加密与上游 sslocal 改造另案处理
签名 project.yml 目标 macOS 15、arm64,Release 使用 Developer ID/hardened runtime;entitlements 当前为空字典 data protection keychain 及跨进程共享不能假定已经配置
凭据事务 CredentialWriteJournal.swift 处理逐项凭据回滚 单密文模型需重看事务边界,不能只替换 JSON encoder

2. 加密目标和边界

本方案主要防止:服务器库被直接打开、误传、单独复制到其他机器或仅文件备份泄漏时暴露服务器地址、分组、订阅和秘密;同时检测文件内容被修改、截断、拼接等导致的认证失败。这一目标来自 AEAD 的保密与完整性能力;AAD 可认证不需要隐藏的文件头。RFC 5116 §2

它不保证以下情况的安全,后续产品描述应保持这个边界:

  • 应用获准解密后,配置与密钥仍会进入进程内存;进程被控制、调试转储或应用主动泄漏不由磁盘加密解决。CryptoKit 的敏感内存处理有帮助,但业务层 Data、String、日志副本不因此自动消失。CryptoKit
  • 文件名、大小、修改时间、写入频率仍可见;整文件加密并不隐藏这些文件系统元数据。这是建议格式的直接推论。
  • 攻击者把整份合法旧密文替换回来时,普通 AEAD 仍会认证成功;防回滚需要额外可信状态,不能靠文件中的 revision 自证最新。RFC 5116 §3
  • 已有运行契约、日志、导出、剪贴板、分享二维码、旧备份及 APFS 快照不因新库文件加密而被追溯擦除。运行契约是否明文以第 1 节代码为依据;其余是后续需单独审计的输出路径。
  • 仅本机存钥降低单独拿到库文件的风险;它不是阻止所有同用户进程、管理员或已授权代码访问的绝对边界。安全性还取决于 Keychain ACL/access group、签名和运行环境。TN3137

3. 候选加密方案比较

方案 适配性与代价 判断
CryptoKit AES-256-GCM 高层 AEAD;加密并验证 AAD;可直接覆盖序列化 Data;无需第三方依赖 推荐
CryptoKit ChaCha20-Poly1305 同样为 AEAD;256 位密钥、96 位 nonce;适合有对应协议互操作要求的情况 可接受备选,无需同时实现两套
AES-CBC + 自行 HMAC/填充/组合 要自行确定认证顺序、密钥分离及错误处理;本任务已有高层 AEAD 可用 不选
SQLCipher/数据库加密 需要改变存储模型、引入数据库及其维护;当前需求为单文档整体保护 目前不选,不否定其数据库场景价值
FileVault/文件权限 对应整卷静态保护与权限控制,不能取代应用所需“文件副本离开机器后仍为密文”的封装 作为互补措施
Secure Enclave 非对称钥匙 + 包装主密钥 增加硬件、访问控制、密钥封装与恢复设计;不是把 AES SymmetricKey 直接放进 Secure Enclave 首版不选

上述算法能力依据 AES.GCM、RFC 8439 §2.8;优先高层接口依据 CryptoKit。CBC、数据库和分层密钥的取舍是针对本项目单文档规模的工程判断。FileVault 的作用范围见 Apple FileVault。

推荐参数与文件封装

建议固定一套 v1 格式:

部分 建议
主密钥 SymmetricKey(size: .bits256) 生成;Keychain 存原始 32 字节,不使用用户密码、设备 ID 或硬编码字符串派生
加密 AES.GCM.seal(payload, using: key, authenticating: headerBytes);每次保存新生成 nonce
nonce/tag 默认 12 字节随机 nonce、16 字节 tag;存储 SealedBox.combined
明文文件头 magic、envelopeVersion、固定算法标识、随机 keyID;不放服务器名、URL、密码或主密钥
认证文件头 对确切 header 字节做 AAD;不要依赖未定义顺序的 JSON 重新编码得出同一 AAD
payload 完整服务器库 DTO 的 JSON/其他现有序列化 Data;其 schemaVersion 与 envelopeVersion 分离
写入 内存序列化 → 内存加密 → 0600 临时密文 → 替换;临时文件、恢复副本也只能写密文

随机主密钥生成 API 见 SymmetricKey.init(size:)。Apple 文档确认 combined 在默认 nonce 下按 nonce、ciphertext、tag 排列;默认 nonce 为 12 字节随机数,且不可重复使用。AES-GCM 的 tag 长度依据 RFC 5116。本任务不应设计持久递增 nonce 计数器,以免文件回滚或并发保存导致重用;库大小及写入频率预计远低于大流量协议,但实施时仍应设定合理文件大小和生命周期上限。combined、Nonce.init、Nonce、RFC 5116 §5.1–5.2

文件头属于本报告提出的应用封装,不是 CryptoKit 自带格式。v1 解码器只接受固定算法和支持的版本,不做失败后换算法或按明文重试;先限制头长度、文件大小并校验结构,再定位 keyID、验证认证,最后解析 payload。随机 keyID 只是主密钥查找标识,不能误当密钥材料。

4. Keychain 选型与后台读取

4.1 主密钥的存法

Apple 的 CryptoKit Keychain 示例把 SymmetricKey 等不能直接作为 SecKey 表达的密钥存为 Generic Password 的二进制值。因此“主密钥存 Generic Password”是官方示范路径;无须为了名字里出现 password 而改用不匹配的 SecKey 包装。Storing CryptoKit Keys in the Keychain

建议服务器库主密钥使用独立 service 命名空间,account 为 keyID;明确 kSecAttrSynchronizable: false。文件头可公开 keyID。不要把主密钥加入导出配置、UserDefaults、日志、崩溃诊断或普通备份清单。同步密钥也不等于同步文件,且 ThisDeviceOnly 与可同步属性不兼容。kSecAttrSynchronizable

4.2 两种 macOS Keychain 不可混为一谈

选择 官方机制 本项目需要验证
经典 file-based / login keychain ACL(SecAccess)控制;默认 keychain 与 search list 决定落点 正式签名升级后授权是否保留、用户手动锁定登录钥匙串、首次访问提示、安装位置变化
data protection keychain 使用 kSecUseDataProtectionKeychain: true 显式选择;用户登录上下文;access groups 与签名 entitlement 相关 App 在发布签名下增删读、权限配置与换 Team 迁移;本次无需 Agent 共享主密钥

Apple 推荐默认选择 data protection keychain;无用户上下文的 launchd daemon 必须使用 file-based keychain。命令行程序即使通过 app-like bundle 承载 entitlement,也仍必须处在用户上下文。用户级 Agent 与系统 daemon 不能因都由 launchd 启动而视为相同环境。TN3137、kSecUseDataProtectionKeychain

特别注意:Apple 对 kSecAttrAccessible 明确要求 macOS 同时选择 data protection keychain 或 synchronizable。因此当前代码仅设置 AfterFirstUnlock,不能证明经典登录钥匙串执行了该保护类;应先确认实际后端,不能照搬 iOS 示例或代码注释。kSecAttrAccessible

对明确采用 data protection keychain 的方案,可在 WhenUnlocked 与 AfterFirstUnlock 之间按产品需求选择。Apple 把 AfterFirstUnlockThisDeviceOnly 描述为适合后台访问、首次解锁后直到重启可用、不可迁移新设备;但本报告不把该通用说明等同于所有 macOS 硬件/版本上的锁屏数据保护保证。Apple 平台安全文档明确指出 macOS 不直接以 Data Protection 实施这些保证;必须用目标 macOS 的真实登录/锁屏/重启情境验证。AfterFirstUnlockThisDeviceOnly、Keychain data protection

结合第 4.3 节的开源签名约束,建议本次验证 App 单进程使用经典登录钥匙串 的严格 ACL、升级授权与恢复;data protection keychain 作为签名团队稳定且迁移路径明确时的备选。本次 catalog 为 GUI 私有,无需向 Agent 扩大主密钥访问权限。

4.3 开源分发、换 Team 与自编译

签名 Team 可能改变,会影响选型;两种 Keychain 都不能承诺换签名后静默读取旧钥。 对本项目这种 Developer ID 开源分发,建议优先采用经典登录钥匙串、保留严格 ACL,并提供显式加密导出/恢复路径。这是考虑维护者变更和用户自编译后的工程取舍;Apple 的平台默认建议仍是 data protection keychain。

DP keychain 的默认组与应用标识有关,通常包含 Team ID / App ID prefix;显式共享组同样受 entitlement 和 provisioning 授权约束。保持 bundle ID、service、account 不变,不能让新 Team 获得旧组的读取权;用户同意弹窗也不是跨组授权机制。这一点由组成员必须匹配的访问规则推导,不能套用经典钥匙串的“Always Allow”。旧版仍能读主密钥时,应先导出完整配置,或实施经验证的桥接迁移,再切换签名;不要等唯一旧读取路径消失后再处理。Sharing access to keychain items、kSecAttrAccessGroup

经典 file-based keychain 使用 ACL,并以代码签名 requirement 跟踪应用身份。常规 Developer ID designated requirement(DR)包含 Team 条件,因此换 Team 也可能不再匹配旧授权。区别是用户可显式批准新应用访问;Apple 的 Keychain Access 指南提供逐个添加应用和 Always Allow 的操作。项目可设计“前台请求重新授权 → 用户确认 → 重试读取”及 Keychain Access 修复指引,但不能保证每种 macOS、ACL 状态和签名变化都会自动弹窗或成功;后台拒绝交互时应停在可恢复错误状态。TN2206、TN3127、Digital signing and encryption、Allow apps to access your keychain

签名变化 预期与限制
同 Team 证书续期/换证书 与换 Team 不同。若 entitlement、应用标识和 DR 保持兼容,常规 DR 不绑定某张证书的指纹;不能因证书文件换了就认定必须丢钥。自定义 ACL 若钉住具体证书则另论
换 Team、改签名身份 DP 可能失去旧组访问;经典 ACL 可能失配。应准备旧版导出/迁移、前台重新授权与独立恢复路径,不能承诺静默升级
用户用自己的 Team 自编译 属于不同身份;相同源码/仓库名不产生旧主密钥访问权。应按显式导入或重新授权处理
无付费 Team / 自签名构建 不假定具备正式发行版的受限 entitlement。经典钥匙串可结合稳定自签名身份使用,但须独立验证;这不等于通过 Developer ID/Gatekeeper 分发检查
ad-hoc(Sign to Run Locally) DR 绑定该次代码,重新构建可能再次触发授权;不能作为跨版本稳定身份。此处的 ad-hoc 指 macOS 本地签名,不是 iOS Ad Hoc 分发

表中同 Team 续期属于依据标准 Developer ID DR 条件的推论,仍需比较实际构建签名;自签名支持依据 TN2206,ad-hoc 限制依据 TN3127。TN3127、TN2206

安全 ACL 应继续限制到获准应用的完整签名身份。不要为了跨 Team 兼容把 requirement 简化为仅 bundle ID,也不要设为任意应用可读。 合法的新身份由用户显式授权或迁移,不应通过放宽所有调用方访问解决。主密钥一旦成为整库唯一解密入口,独立恢复导出的价值也高于当前可逐项重新输入密码的模型。

4.4 无交互读取与失败语义

自动启动、后台重连不应依赖 Touch ID/userPresence 等每次交互授权。禁交互只能让需要认证的操作失败,不会绕过保护。Apple 已弃用 kSecUseAuthenticationUIFail,建议在适用路径使用 kSecUseAuthenticationContext 搭配 LAContext.interactionNotAllowed;经典 file-based 后端须另外核实其行为,不能由 LAContext 文档推断通用兼容。kSecUseAuthenticationUIFail、LAContext.interactionNotAllowed

实现应区分:不存在、被锁定/需要交互、缺 entitlement/无权访问、用户取消、文件认证失败、格式版本不支持。保留原密文、阻止自动写入,提示可恢复的状态;不要把所有错误归为“还没有配置”。

4.5 Secure Enclave 为什么不作为首选

Secure Enclave 的 P-256 接口处理签名/密钥协商,不能把现有任意 AES 对称主密钥导入后要求硬件直接完成文件 AES-GCM。Apple 对 P-256 路径说明须在 Enclave 内生成私钥且不可迁出;间接保护文档需要额外的密钥协商/封装方案,并增加设备绑定与恢复限制。这并不能消除业务明文进入 App 内存的事实。Protecting keys with the Secure Enclave、SecureEnclave.P256

若未来产品明确要求硬件绑定、限制私钥可导出或交互解锁,可再研究 envelope/key wrapping;对当前目标,Keychain 保存随机对称主密钥更直接。

5. 密钥与文件生命周期建议

本节是由 AEAD/Keychain 能力推导的应用事务建议,不是 Apple 提供的跨 Keychain/文件原子事务。

初始化与并发

  • 只有确认没有已有库文件,才允许生成新库与新主密钥。已有密文但查不到 keyID 时必须进入恢复状态,禁止生成新钥后覆盖。
  • 单进程串行化保存;若可能多进程初始化,用稳定 bootstrap 定位方式和独占协调防止双库。SecItemAdd 返回 duplicate 时重读已有项,不能继续用自己尚未获持久保存的候选密钥;duplicate 的复合主键规则见 kSecClassGenericPassword。
  • Keychain 写入成功并重新读取校验后,才提交用该密钥加密的首份文件。文件提交失败可留下孤立密钥,后续辨认/清理;宁可留多余钥匙,也不能生成无钥可开的唯一密文。

旧数据迁移

  1. 按原版本读取 catalog,解析全部密码、插件参数、订阅 URL。任意旧凭据缺失/被锁定就停止迁移,不能静默变空。
  2. 在内存构建完整新 payload;通过旧库与新 payload 的语义比对确认没有漏字段、排序、订阅状态或秘密。
  3. 创建并验证主密钥,在内存加密 payload,原子提交新格式,再重新打开并验证;迁移中断后能够根据磁盘与密钥状态重试。
  4. 只有新库可读且恢复策略落实后,才清理已迁入的逐项凭据;范围外的 Keychain 项保留。
  5. 是否临时保留旧明文备份须明确选择:默认建议不新增持久明文备份,安全提交前以旧文件作为唯一来源;安全提交后清理旧路径。若为了回退另存明文,会延长敏感元数据泄漏窗口,必须定义期限与说明。

已存在的旧备份/快照无法靠删除当前路径保证消失;不承诺迁移后的“安全擦除”。旧版本程序对新密文应明确拒绝打开,不能再创建或覆盖同路径空库。

损坏、轮换与恢复

文件加载至少区分 missing、keyUnavailable、authenticationFailed、unsupportedVersion、malformedPayload;仅 missing 可进入新建流程。认证失败不尝试解码未经认证的明文,也不自动恢复空库。AES.GCM.open

轮换时先创建新 keyID/密钥,以新密钥生成并验证新文件,然后替换;旧钥保留到旧密文备份不再需要。不能先删除旧钥。若要保留多个历史密文,必须同时定义其 keyID/密钥保留期限;“轮换”不会让持有旧密钥和旧备份的攻击者失去旧数据。

恢复政策必须在实施前明确:

政策 收益 必须说明的后果
本机绑定 / ThisDeviceOnly 限制主密钥迁移 仅有密文备份、换机或钥匙串丢失时无法恢复;应提供受控导出/重新订阅路径
可迁移但不同步的 Keychain 项 可配合系统迁移/备份场景 必须实测所支持的迁移/恢复方式,不能承诺复制 catalog 即可用
显式密码加密导出 便于跨机器恢复,与本机主密钥解耦 需要独立 KDF、口令策略与导出格式研究,不应直接复用弱口令作为 AES key
iCloud 同步主密钥 可能支持多机共享钥匙 文件并不同步;扩大恢复和同步状态空间,首版不推荐

Apple 对 ThisDeviceOnly 明确规定不迁移到其他设备;对 synchronizable 明确规定其同步语义。经典 keychain 的文件级备份也不能作为未经验证的恢复承诺;产品应验证系统支持的迁移与恢复流程,避免依赖内部存储布局。AfterFirstUnlockThisDeviceOnly、kSecAttrSynchronizable、TN3137

6. 已明确的任务边界

本次只覆盖持久服务器库整体加密。sslocal-active.json 的加密及所需上游 sslocal 改造另案处理,不作为本次设计、实现或验收的前置条件。运行契约与派生认证文件沿用现有机制,本次不要求 Agent 读取服务器库主密钥。

当前 wrapper 向 sslocal 传递 JSON 文件路径,服务器库与运行契约是两个独立存储边界;本次成果应表述为“服务器库整体加密”。保留该事实用于准确描述保护范围,不在本文继续展开运行时加密候选方案。AgentContract.swift、RuntimePaths.swift

7. 实施前验证与待决定事项

本文仅完成源码与一手资料调研,未实现加密、未对真实 Keychain 写入或进行签名发布验证。

建议后续采用以下有区分的证据:

验证层 验收重点
纯函数/文件测试 完整 payload 往返、空库/大库、错误钥匙、篡改 nonce/tag/header、截断、未知版本;相同 payload 多次保存产生不同密文
迁移故障注入 缺旧凭据、Keychain 写失败、duplicate 并发、写文件/替换失败、任一步退出;不覆盖旧数据、不丢钥匙、不把损坏当空库
文件检查 新库及临时/恢复文件不含样本服务器名、地址、密码、插件参数和订阅 URL;权限保持;未进行安全擦除保证
真机 Keychain Debug 与正式 Developer ID 构建分别验证;新增、同 Team 续证、换 Team、自编译/ad-hoc 重建、前台重新授权、升级、撤销授权、锁钥匙串、锁屏、重启首次登录、退出 GUI、切换用户;记录 OSStatus,不泄漏值
恢复验证 同机恢复、换机恢复、只有密文无钥匙、钥匙丢失、轮换后恢复旧备份;按选择的策略说明能否恢复
运行进程 检查加密库加载及凭据来源调整后,现有激活与运行流程仍兼容;不要求运行契约或派生认证文件加密

下一步需要确定的产品边界:

  1. 是否采用推荐的经典登录钥匙串 + 严格 ACL,换签名时如何授权或迁移?
  2. 希望默认本机绑定,还是支持迁移备份;丢钥匙时允许重新导入/订阅,还是必须提供独立加密导出?
  3. 钥匙串锁定后的 GUI 行为、内存密钥缓存期限,以及何时阻止编辑或重新生成运行配置?
  4. 迁移提交后旧凭据/旧明文与轮换历史钥匙保留多久?

算法本身已有足够依据选择 AES-256-GCM。上述边界会影响恢复能力、库加载行为和授权体验,宜在实现前形成设计决定。