mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-03 01:23:43 +00:00
refactor: adopt age configuration storage
Replace the custom encrypted container with AgeSwift 0.1.0 so standard age tools can inspect sensitive configuration independently of the app. Use independent Keychain X25519 identities for catalog.age and proxy-listening.age. Preserve atomic commits and confirmed recovery; reject historical NG2 formats without migration or cleanup.
This commit is contained in:
@@ -1,12 +1,12 @@
|
||||
# 持久服务器库整体加密与主密钥恢复边界
|
||||
|
||||
后续修订:范围外的监听配置采用独立主密钥整体加密,见 [ADR-0042](0042-encrypt-proxy-listening-configuration.md);该修订已实施,服务器库自身的恢复规则保持不变。
|
||||
后续修订:加密格式、文件名、identity 存储和匹配方式按 [ADR-0050](0050-age-encrypted-configuration-storage.md) 执行;范围外的监听配置采用独立主密钥整体加密,见 [ADR-0042](0042-encrypt-proxy-listening-configuration.md);该修订已实施,服务器库自身的恢复规则保持不变。
|
||||
|
||||
Status: Accepted(2026-10-18)。
|
||||
|
||||
实施状态:整体加密、记录直接携带秘密及移除 NG2 历史格式兼容均已实施。当前实现以代码为准,验证结果见对应交付记录。
|
||||
|
||||
持久服务器库采用 CryptoKit AES-256-GCM 整体加密。密码、完整插件参数、订阅 URL 与配置目录、订阅关联、收藏共同进入密文;服务器库专用 Keychain 项只保存随机主密钥,不再逐项保存这些凭据。监听认证等范围外凭据保持独立存储;测试 URL 按 ADR-0036 保存为非敏感偏好。`sslocal-active.json` 与派生认证文件的加密另案处理。
|
||||
持久服务器库采用标准 age 文件格式整体加密,通过 AgeSwift 使用 X25519 identity,详见 [ADR-0050](0050-age-encrypted-configuration-storage.md)。密码、完整插件参数、订阅 URL 与配置目录、订阅关联、收藏共同进入密文;服务器库专用 Keychain 项只保存标准 age identity 私钥文本,不再逐项保存这些凭据。监听认证等范围外凭据保持独立存储;测试 URL 按 ADR-0036 保存为非敏感偏好。`sslocal-active.json` 与派生认证文件的加密另案处理。
|
||||
|
||||
主密钥采用经典登录钥匙串,并以严格 ACL 限制获准应用访问。该选择优先考虑开源分发中的维护者签名变更和用户自行编译;不通过放宽到任意应用访问来换取兼容。更换签名后的前台重新授权与迁移路径仍须设计,并在真实签名构建中验证,不能承诺静默读取旧密钥。
|
||||
|
||||
@@ -40,17 +40,17 @@ Status: Accepted(2026-10-18)。
|
||||
|
||||
NG2 尚未发版,不再支持其历史数据迁移,包括旧 `catalog.json` 与逐项 Keychain 凭据,以及已实现的「目录 + 引用秘密表」加密格式。新记录格式使用明确的新版本,只读取当前格式;移除旧版本兼容解码、自动迁移与迁移清理,不读取或清理旧逐项 Keychain 凭据。
|
||||
|
||||
持久文件路径继续使用 `catalog.enc`。发现旧格式库时报告不支持的格式,阻止写入,不自动转换、不退回其他旧文件,也不静默当作新安装。只有当前库与旧库均不存在时才按全新空库处理;旧 `catalog.json` 的存在检查只用于识别旧库,不读取其内容。用户仍可明确确认放弃旧库并创建空库;该操作只提交新的 `catalog.enc`,不增加对旧文件或旧 Keychain 项的清理。
|
||||
持久文件路径按 ADR-0050 改为 `catalog.age`。发现旧自研 `catalog.enc` 或旧格式库时报告不支持的格式,阻止写入,不自动转换、不退回其他旧文件,也不静默当作新安装。只有当前库与历史 `.enc`、`.json` 均不存在时才按全新空库处理;旧 `catalog.json` 的存在检查只用于识别旧库,不读取其内容。用户仍可明确确认放弃旧库并创建空库;该操作只提交新的 `catalog.age`,不增加对旧文件或旧 Keychain 项的清理。
|
||||
|
||||
保留用户主动执行的旧版 ShadowsocksX-NG 服务器导入与再次导入。这是 ADR-0002 确定的产品功能,不属于 NG2 内部格式迁移。导入直接构造携带凭据值的手动记录,保留既定身份、完成标记和失败恢复语义;不导入旧版偏好或改写旧版数据。
|
||||
|
||||
## 主密钥生命周期
|
||||
|
||||
首版不提供手动或定期轮换入口。正常保存沿用现有主密钥,每次加密生成新 nonce;用户明确重置服务器库时创建新主密钥。密钥创建与首份文件提交的失败保护属于实现契约,不得先删除旧密钥再尝试写入新文件。
|
||||
首版不提供手动或定期轮换入口。正常保存沿用匹配的 identity,每次生成新的 age 密文;用户明确重置服务器库时创建新主密钥。密钥创建与首份文件提交的失败保护属于实现契约,不得先删除旧密钥再尝试写入新文件。
|
||||
|
||||
## 实施与验收
|
||||
|
||||
实现保持单应用进程、串行保存与单份主密钥模型,不引入多进程迁移协调、自动轮换或历史密文恢复系统。固定版本化文件封装,认证文件头;服务器库保存过程只写密文临时文件,不向日志或诊断报告增加秘密。主密钥不写入普通文件、日志或诊断报告;运行契约沿用本文开头规定的范围。磁盘权限沿用目录 0700、文件 0600。
|
||||
实现保持单应用进程、串行保存与单个已匹配 identity 的内存模型;Keychain 保留重置前的 identity,不引入多进程迁移协调、自动轮换或历史密文恢复系统。采用标准 age v1,不附加自研文件头;JSON 结构版本在密文内,读取兼容二进制和 ASCII armor。服务器库保存过程只写密文临时文件,不向日志或诊断报告增加秘密。主密钥不写入普通文件、日志或诊断报告;运行契约沿用本文开头规定的范围。磁盘权限沿用目录 0700、文件 0600。
|
||||
|
||||
验收集中于本次实际改变的路径:
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**状态**:已接受并实施(2026-10-19)。
|
||||
|
||||
代理监听配置改用与服务器库相同的主密钥加密方法,但保留独立文件和独立主密钥。复用 AES-256-GCM 加密与经典 Keychain 主密钥存储的实现,不共用服务器库当前的实际密钥。服务器库重置不改变监听配置及其密钥,监听配置重置也不改变服务器库。
|
||||
代理监听配置改用与服务器库相同的主密钥加密方法,但保留独立文件和独立主密钥。按 [ADR-0050](0050-age-encrypted-configuration-storage.md) 复用 AgeSwift 的标准 age 文件加密与经典 Keychain identity 存储的实现,不共用服务器库当前的实际密钥。服务器库重置不改变监听配置及其密钥,监听配置重置也不改变服务器库。
|
||||
|
||||
用户名和密码随所属监听配置整体加密保存。移除逐项凭据存储、密码引用及跨存储提交补偿;保留完整配置校验、原子保存、保存失败不发布,以及保存成功后再执行运行状态收敛的契约。运行配置准备、编辑与终端命令从已解密内存读取所需值;状态投影、日志与诊断不得因此携带密码。
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
|
||||
独立密钥使两个存储 module 保持各自的生命周期与恢复规则。共用实际密钥虽然减少一条 Keychain 记录,却要求协调两份密文的首次创建、读取和重置,收益不足。
|
||||
|
||||
不迁移旧监听 JSON,也不读取或清理旧逐项 Keychain 凭据。检测到旧监听文件时报告格式不支持,由用户明确确认后重置,不静默忽略旧文件并使用默认值。
|
||||
不迁移旧自研 `proxy-listening.enc` 或旧监听 JSON,也不读取或清理旧逐项 Keychain 凭据。检测到旧监听文件时报告格式不支持,由用户明确确认后重置,不静默忽略旧文件并使用默认值。
|
||||
|
||||
本决定修订 ADR-0034、ADR-0035、ADR-0041 对监听密码继续独立保存于 Keychain 的规定。ADR-0041 对监听配置与独立偏好的存储范围分离继续有效。
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# 使用 age 标准格式保存敏感配置
|
||||
|
||||
**状态**:已接受并实施(2026-10-21);用户通过 implement 指令确认实施。验证结果见交付记录。
|
||||
|
||||
敏感配置的加密存储采用标准 [age 文件格式](https://age-encryption.org/),使用 [AgeSwift](https://github.com/qiuyuzhou/AgeSwift) 实现。目的是减少自研加密协议与文件容器的维护成本,让维护者能够使用标准 age 兼容命令行工具解密检查配置,并使配置文件长期独立于特定语言、实现与维护者。此选择的主要目标不是提高现有方案的密码学强度。
|
||||
|
||||
该决定修订 [ADR-0035](0035-encrypt-persistent-server-library.md) 与 [ADR-0042](0042-encrypt-proxy-listening-configuration.md) 中涉及的加密方法和文件封装规定。当前行为仍以代码为准。
|
||||
|
||||
## 已确认的范围与兼容边界
|
||||
|
||||
服务器库和代理监听配置同时替换自研加密格式。运行配置与 LAN 认证文件不在本次范围内,其加密另案处理。
|
||||
|
||||
延续 NG2 尚未发版、不迁移历史格式的决定。现有自研加密文件属于不支持的历史格式:保留原文件并阻止普通写入,不自动转换,不静默按新安装处理。沿用各自既有的用户明确确认重置流程;未确认重置不覆盖数据。此决定不授权在设计讨论期间重置本机配置或 Keychain 项。
|
||||
|
||||
## 已确认的格式与密钥
|
||||
|
||||
直接保存标准二进制 age 文件,不在 age 文件外增加自定义文件头。服务器库文件名改为 `catalog.age`,代理监听配置文件名改为 `proxy-listening.age`;配置结构版本放在加密后的 JSON 内。检测到旧 `.enc` 文件时,仍按不支持的历史格式处理,不因新路径不存在而创建空配置。
|
||||
|
||||
两个存储分别生成独立的 X25519 identity,将标准 `AGE-SECRET-KEY-1...` 私钥文本保存在经典登录钥匙串中,继续限制获准应用访问。正常保存复用 identity,用户明确确认重置时生成新的 identity。
|
||||
|
||||
按存储分别建立新的 age Keychain 命名空间,读取其中的 identity,由 AgeSwift 尝试匹配解密,不增加密钥索引文件。成功加载后缓存匹配的 identity,正常保存继续使用它。重置时先保存并回读新 identity,再原子替换密文,保留旧 identity;写盘失败或中途退出时,旧文件仍能用旧 identity 解密。密钥记录随重置次数增加,接受相应读取成本。
|
||||
|
||||
保留既有加载失败后的用户确认重置入口,涵盖文件损坏、格式不支持和密钥不可用等情况,不收窄为仅 identity 丢失才能重置。加载失败不自动判定 identity 丢失,也不自动重置。
|
||||
|
||||
## 标准工具与可读格式
|
||||
|
||||
开发文档说明如何使用系统 Keychain 工具取得 identity,再使用标准 `age` CLI 解密;不新增应用内私钥导出界面。保留系统授权要求,私钥不进入日志或诊断报告。
|
||||
|
||||
应用接受二进制与 ASCII armor 两种标准 age 文件,保存时统一写二进制。解密仍使用对应存储的 identity,解密后的 JSON 必须通过原有结构与内容校验。不增加口令加密、SSH 密钥或多接收方管理功能。
|
||||
|
||||
## 沿用的行为与失败保护
|
||||
|
||||
两个存储继续拥有独立的密钥与恢复生命周期:服务器库加载失败时保留原文件、阻止写入并提供重试或确认创建空库;监听配置加载失败时阻断正常工作区启动,用户确认重置成功后才解除阻断。失败或等待确认期间,保留既有后台代理和系统代理;成功保存后再按原有规则收敛运行状态。
|
||||
|
||||
保留后台读取不触发 Keychain 授权弹窗、显式交互入口控制授权、成功读取后在当前进程缓存的规则。Keychain 某个候选 identity 不可读取时,不将其视为文件损坏或自动新建密钥;能否用其他可访问候选正常读取,应以实际解密结果为准。没有可用匹配 identity 时报告读取失败并保留文件。
|
||||
|
||||
保留完整值校验、串行保存、仅写密文临时文件、同目录原子替换和保存成功后才发布内存值的契约。目录权限仍为 0700,文件为 0600。服务器库和监听配置的明文大小上限分别保持 64 MiB 和 1 MiB,读取前限制密文大小并考虑 age 分块与 armor 开销,解密后再次检查明文大小。AgeSwift 提供内存加解密 API,不承担文件提交、Keychain 或大小限制。
|
||||
|
||||
不迁移、读取或清理旧 AES 密钥记录;不增加自动密钥清理、轮换、完整库加密备份或手动锁定功能。用户主动执行的旧版 ShadowsocksX-NG 导入与现有配置组导出、分享保持既有用途。
|
||||
|
||||
## 实施与验收要求
|
||||
|
||||
通过 Swift Package Manager 精确固定 AgeSwift 0.1.0;其 Swift API 底层使用 Rust age 与预编译 XCFramework。实际接入时核实发布产物、校验和及应用和测试目标的链接,不以本地同名仓库作为交付依赖。移除生产自研 AES 文件封装读写,更新相关格式文档及 ADR-0035、ADR-0042 的现行描述,历史研究报告保留原时点内容。
|
||||
|
||||
验收覆盖两个存储的完整字段往返、重复保存密文不同、错误或缺失 identity、篡改与截断、大小限制、JSON 结构版本与内容校验,以及二进制和 armor 读取。使用标准 age CLI 与应用实现做双向互操作验证,不仅验证库内自行加解密。
|
||||
|
||||
验证旧 `.enc` 和旧 JSON 的存在不会触发静默初始化;新 `.age` 存在时以新文件为准,不回退到旧文件。验证提交失败与重置中断保留旧文件和旧 identity 的可读性、两个存储恢复互不影响,以及启动恢复门禁。真机核实 Keychain ACL、后台非交互读取、显式授权和退出重启后的读取,并验证文档中的系统 Keychain 工具与 age CLI 操作;未执行的验证如实标为未验证。
|
||||
@@ -34,9 +34,9 @@
|
||||
|
||||
局域网 SOCKS 的 UDP 中继和 SOCKS/HTTP 共用的身份验证独立设置。用户名与密码随监听配置整体加密保存,Keychain 仅保存监听专用主密钥;运行时派生当前用户专用的认证文件;上游认证失败日志中的密码会隐藏。设置只检查 TCP 占用,不设置独立 UDP 端口。首页命令按选择地址使用本地或局域网端口,局域网身份验证开启时加入编码后的凭据;tooltip 显示完整命令。
|
||||
|
||||
监听配置单独加密保存在 `proxy-listening.enc`,只包含本地与局域网监听相关值。代理模式、规则默认动作、代理服务开关、系统代理开关和附加系统代理例外以 `proxyControl.` 前缀的独立键保存在 UserDefaults;运行计算的内存快照不代表统一持久化文档。监听仍采用联合校验与原子文件保存;UserDefaults 偏好不提供同步落盘失败的报错与回滚。
|
||||
监听配置使用标准 age 文件格式与独立 X25519 identity,单独加密保存在 `proxy-listening.age`,只包含本地与局域网监听相关值。代理模式、规则默认动作、代理服务开关、系统代理开关和附加系统代理例外以 `proxyControl.` 前缀的独立键保存在 UserDefaults;运行计算的内存快照不代表统一持久化文档。监听仍采用联合校验与原子文件保存;UserDefaults 偏好不提供同步落盘失败的报错与回滚。
|
||||
|
||||
不迁移旧 `settings.json`:新监听文件与新增偏好缺值时使用默认值,需要重新设置现有开发配置;旧文件不读取、不删除,遗留 Keychain 条目不访问、不清理。已有语言、静默启动、测试 URL 等独立偏好及服务器资料保持原状。监听读取失败会阻断正常启动,包括静默启动,并显示只含“重置监听配置”的说明性 alert。用户确认后创建新监听主密钥并原子保存默认配置,默认关闭局域网监听。重置成功前保留原文件及已有后台代理和系统代理;成功后按默认配置与原有开关意图收敛。不迁移旧 `proxy-listening.json`,不读取或清理旧逐项 Keychain 凭据;发现旧文件时同样提示确认重置。设计见 [ADR-0034](adr/0034-separate-local-and-lan-listeners.md)、[ADR-0040](adr/0040-keep-settings-recovery-simple.md) 、[ADR-0041](adr/0041-separate-preferences-from-proxy-listening-configuration.md) 和 [ADR-0042](adr/0042-encrypt-proxy-listening-configuration.md)。
|
||||
不迁移旧 `settings.json`:新监听文件与新增偏好缺值时使用默认值,需要重新设置现有开发配置;旧文件不读取、不删除,遗留 Keychain 条目不访问、不清理。已有语言、静默启动、测试 URL 等独立偏好及服务器资料保持原状。监听读取失败会阻断正常启动,包括静默启动,并显示只含“重置监听配置”的说明性 alert。用户确认后创建新监听主密钥并原子保存默认配置,默认关闭局域网监听。重置成功前保留原文件及已有后台代理和系统代理;成功后按默认配置与原有开关意图收敛。不迁移旧 `proxy-listening.enc` 或 `proxy-listening.json`,不读取或清理旧逐项 Keychain 凭据;发现旧文件时同样提示确认重置。设计见 [ADR-0034](adr/0034-separate-local-and-lan-listeners.md)、[ADR-0040](adr/0040-keep-settings-recovery-simple.md)、[ADR-0041](adr/0041-separate-preferences-from-proxy-listening-configuration.md)、[ADR-0042](adr/0042-encrypt-proxy-listening-configuration.md) 和 [ADR-0050](adr/0050-age-encrypted-configuration-storage.md)。
|
||||
|
||||
## 应用支持目录
|
||||
|
||||
@@ -44,12 +44,12 @@
|
||||
|
||||
| 子目录 | 文件与用途 |
|
||||
| --- | --- |
|
||||
| `configuration/` | `catalog.enc`、`proxy-listening.enc`、`activation.json`、`custom-rules.json`、`plugins.json`,由 GUI 管理的持久资料 |
|
||||
| `configuration/` | `catalog.age`、`proxy-listening.age`、`activation.json`、`custom-rules.json`、`plugins.json`,由 GUI 管理的持久资料 |
|
||||
| `runtime/` | 监管资料、运行回执、失败记录、PID、稳定链接,以及代次命名的配置、GUI 私有意图摘要、认证与 ACL 文件 |
|
||||
| `logs/` | `agent.log`,停止后台代理后保留 |
|
||||
|
||||
`runtime-generation`、`runtime.lock`、`agent.lock` 位于 `runtime/`,停止服务仍保留。不能把停止操作实现为删除整个运行目录。目录权限为 `0700`,普通文件权限为 `0600`;运行配置和认证文件仍可能包含明文秘密。UserDefaults、Keychain、LaunchAgent plist 及 bundle 内规则与插件继续使用各自的位置。
|
||||
|
||||
此次布局调整不提供自动迁移。旧根目录文件不读取、不移动、不删除,包括初版 `silent-launch.json`;已保存的 UserDefaults 偏好不变。开发环境切换前,退出 GUI 并停止旧 Agent 与 sslocal,确认进程退出后,将需要保留的上述五份 GUI 文件原样搬到 `configuration/`,不要搬移运行产物。密文文件中的密钥标识不变,同一用户的原 Keychain 主密钥可继续使用;不应只复制密文到另一台机器并假定能够解密。设置目录权限为 `0700`、文件权限为 `0600`,再启动新构建,由新路径重新派生运行产物。未搬移配置时,新目录按首次使用初始化;不会从旧根目录补读。新目录内旧格式文件的拒绝规则仍保留。
|
||||
此次布局调整不提供自动迁移。旧根目录文件不读取、不移动、不删除,包括初版 `silent-launch.json`;已保存的 UserDefaults 偏好不变。开发环境切换前,退出 GUI 并停止旧 Agent 与 sslocal,确认进程退出后,将需要保留的上述五份 GUI 文件原样搬到 `configuration/`,不要搬移运行产物。当前 age 文件可沿用同一用户对应 service 中的 Keychain identity;历史自研 `.enc` 文件不迁移,不能只改扩展名继续使用。不应只复制密文到另一台机器并假定能够解密。设置目录权限为 `0700`、文件权限为 `0600`,再启动新构建,由新路径重新派生运行产物。未搬移配置时,新目录按首次使用初始化;不会从旧根目录补读。新目录内旧格式文件的拒绝规则仍保留。
|
||||
|
||||
路径规则集中在 `ApplicationSupportPaths` 与 `RuntimePaths`。Agent 的测试覆盖中,`SSXNG_CONTRACT_PATH` 优先于 `SSXNG_RUNTIME_DIR`,同一部署全部路径由该契约所在目录派生;仅设置运行目录时使用标准配置链接名。`SSXNG_LOG_PATH` 可独立指定日志位置。指定测试契约或运行目录且未指定日志时,日志留在被覆盖的运行目录,避免测试写入生产日志。
|
||||
|
||||
@@ -53,6 +53,8 @@ Scripts/fetch-external-binaries.sh
|
||||
task gen:prj
|
||||
```
|
||||
|
||||
Swift Package Manager 从 `project.yml` 固定的正式版本解析 AgeSwift,并校验其发布 XCFramework。首次解析需要网络,消费者无需安装 Rust。`Vendor/AgeSwift/LICENSE-MIT` 来自固定版本,随应用打包到 `Resources/ThirdParty/AgeSwift/`。加密存储互操作测试还需要 `brew install age` 和可访问的登录钥匙串;测试使用 UUID 隔离的 service 并清理,不修改真实应用配置。命令行调试方法见[加密格式文档](server-library-encryption-format.zh-CN.md)。
|
||||
|
||||
无证书也能构建与跑单测:Debug 基线是 ad-hoc,不需要 `Configs/Signing.local.xcconfig`。只有要做 Developer ID 签名(发布,或让本机 Debug 产物过打包门槛)时才写该文件,内容见[签名与运行形态基线](distribution.md#签名与运行形态基线)。
|
||||
|
||||
`Tests/Smoke/` 是真实进程冒烟(经 wrapper 拉起真实 sslocal 验证启动、握手、
|
||||
|
||||
@@ -1,91 +1,23 @@
|
||||
# Server Library Encryption File Format v2
|
||||
# Sensitive Configuration Encryption with age
|
||||
|
||||
English | [简体中文](server-library-encryption-format.zh-CN.md)
|
||||
|
||||
Recorded on 2026-10-18. Implementation snapshot: `507917a`.
|
||||
The server library and proxy listening configuration use standard [age v1](https://age-encryption.org/v1) files through [AgeSwift](https://github.com/qiuyuzhou/AgeSwift). See [ADR-0050](adr/0050-age-encrypted-configuration-storage.md) for the decision and recovery boundaries.
|
||||
|
||||
This document describes the `catalog.enc` format at that commit. It supports maintenance, troubleshooting, and compatible reader implementations. This is a point-in-time format description, not a continuously maintained account of the latest implementation. The source code and [ADR-0035](adr/0035-encrypt-persistent-server-library.md) define current behavior.
|
||||
## Files and payloads
|
||||
|
||||
## 1. File and Protection Scope
|
||||
Both files live in `~/Library/Application Support/ShadowsocksX-NG2/configuration/`:
|
||||
|
||||
The default path is `~/Library/Application Support/ShadowsocksX-NG2/configuration/catalog.enc`. The file is binary:
|
||||
|
||||
```text
|
||||
complete file = plaintext header || nonce || ciphertext || tag
|
||||
additional authenticated data (AAD) = the original 45 header bytes
|
||||
algorithm = AES-256-GCM
|
||||
```
|
||||
|
||||
`||` means byte concatenation. The header is authenticated but not encrypted. The file does not contain the master key.
|
||||
|
||||
The ciphertext contains the complete configuration catalog, subscription records, ordered favorites, with passwords, complete plugin options, and subscription URLs stored directly in their owning records. This format does not cover the runtime configuration `sslocal-active.json`, derived listener authentication files, or files that users explicitly share or export.
|
||||
|
||||
## 2. Binary Layout
|
||||
|
||||
Offsets start at zero. All offsets and lengths are in bytes. Let `P` be the UTF-8 byte length of the complete payload before encryption. The AES-GCM ciphertext also has length `P`.
|
||||
|
||||
| Offset | Length | Field | Encoding and Meaning |
|
||||
| --- | --- | --- | --- |
|
||||
| 0 | 8 | magic | ASCII `SSXNG2LB`; hexadecimal `53 53 58 4E 47 32 4C 42` |
|
||||
| 8 | 1 | envelopeVersion | The binary value `0x02`, not the character `2` |
|
||||
| 9 | 36 | keyID | A UUID encoded as UTF-8 text, including hyphens, without quotes, a terminating zero byte, or a newline |
|
||||
| 45 | 12 | nonce | A random nonce generated by CryptoKit with its default configuration |
|
||||
| 57 | `P` | ciphertext | AES-GCM ciphertext of the complete payload |
|
||||
| `57 + P` | 16 | tag | GCM authentication tag |
|
||||
|
||||
Thus:
|
||||
|
||||
```text
|
||||
headerLength = 8 + 1 + 36 = 45
|
||||
combined = nonce || ciphertext || tag
|
||||
fileLength = P + 73
|
||||
P = fileLength - 73
|
||||
```
|
||||
|
||||
The file has no additional length field, padding, separator, compression layer, or outer Base64 encoding. An example `keyID` is `65A4F8AE-28F6-46A2-B61F-7CFA9A85D7F2`. The writer generates this text with `UUID.uuidString`. Do not substitute the UUID's 16-byte binary representation.
|
||||
|
||||
Version 2 has no separate algorithm field. Version `0x02` specifies AES-256-GCM. The reader does not try a different algorithm after a failure.
|
||||
|
||||
## 3. Master Key and Authentication
|
||||
|
||||
`SymmetricKey(size: .bits256)` generates a random 32-byte master key. The key is neither a user password nor a value derived from `keyID`.
|
||||
|
||||
Production storage uses a Generic Password item in the classic keychain:
|
||||
|
||||
| Attribute | Value |
|
||||
| --- | --- |
|
||||
| `kSecClass` | `kSecClassGenericPassword` |
|
||||
| `kSecAttrService` | `com.qiuyuzhou.ShadowsocksX-NG2.server-library` |
|
||||
| `kSecAttrAccount` | The `uuidString` of the UUID parsed from the file header |
|
||||
| `kSecValueData` | The raw 32-byte master key, not hexadecimal or Base64 text |
|
||||
| `kSecAttrSynchronizable` | `false` |
|
||||
| `kSecUseDataProtectionKeychain` | `false` |
|
||||
|
||||
When creating the item, `SecAccessCreate` receives a trusted application list containing only the current application. The header identifies the key; it does not grant keychain access. A copy of `catalog.enc` without the corresponding master key cannot decrypt the server library.
|
||||
|
||||
The writer uses CryptoKit's default nonce generation and generates a new nonce for each save. Normal saves retain the master key. A user-confirmed library reset generates a new key. The nonce and tag use the layout provided by `AES.GCM.SealedBox.combined`.
|
||||
|
||||
Encryption and decryption both use the original header bytes as AAD. A compatible reader must use the original 45 bytes from the file. Do not replace them with a header reconstructed after parsing the UUID.
|
||||
|
||||
## 4. Decrypted Payload
|
||||
|
||||
Decryption produces the catalog itself as UTF-8 JSON. There is no outer `catalog`/`secrets` object, nested Base64 catalog, or separate credential reference mapping.
|
||||
|
||||
The writer emits catalog schema v7, and the reader accepts only v7. This schema version is independent of the header's envelopeVersion: `0x02` does not mean catalog schema v2.
|
||||
|
||||
### 4.1 Catalog JSON
|
||||
|
||||
All five top-level fields are required, including arrays that are empty:
|
||||
|
||||
| Field | JSON Type | Meaning |
|
||||
| File | Decrypted UTF-8 JSON | Maximum plaintext size |
|
||||
| --- | --- | --- |
|
||||
| `version` | number | Catalog schema version, exactly `7` |
|
||||
| `rootChildren` | array | Ordered root-level node identities |
|
||||
| `entries` | array | All nodes, sorted by node `id.rawValue` when written |
|
||||
| `subscriptions` | array | Subscription records, including their URL values and refresh information |
|
||||
| `favoriteIDs` | array | Ordered favorite node identities |
|
||||
| `catalog.age` | Catalog schema v7: `version`, `rootChildren`, `entries`, `subscriptions`, `favoriteIDs` | 64 MiB |
|
||||
| `proxy-listening.age` | `{ "version": 1, "configuration": { "local": …, "lan": … } }` | 1 MiB |
|
||||
|
||||
An empty library has this payload:
|
||||
The server library contains the entire catalog, subscriptions and ordered favorites, including server passwords, complete plugin options and subscription URLs in their owning records. Listener credentials belong to the separate listening configuration. Runtime JSON, LAN authentication sidecars, user exports and sharing are outside this format's scope.
|
||||
|
||||
Writers produce binary age v1, with a single X25519 recipient and no application header, UUID prefix, sidecar index, outer Base64 layer or custom cryptographic container. Readers also accept standard ASCII armor. Saving an armored input writes binary age. JSON schema versions are inside the encrypted payload, independent of the age format version. Readers validate the complete decoded model before publishing it.
|
||||
|
||||
An empty catalog payload is:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -97,77 +29,56 @@ An empty library has this payload:
|
||||
}
|
||||
```
|
||||
|
||||
`rootChildren` and each configuration group's child array define hierarchy and display order. Do not substitute the order of the `entries` array. The writer enables `prettyPrinted` and `sortedKeys`; compatible readers must not depend on whitespace or object key order.
|
||||
Catalog hierarchy and ordering come from `rootChildren` and group child arrays. Secrets remain ordinary record values, not credential references. Codable models define the full schema; whitespace and object key order carry no meaning.
|
||||
|
||||
### 4.2 Secrets Belong to Records
|
||||
## Keychain identities
|
||||
|
||||
| Owning Model | Field | JSON Type | Meaning |
|
||||
| --- | --- | --- | --- |
|
||||
| `ServerFields` | `password` | string | Server password |
|
||||
| `ServerFields` | `pluginOptions` | optional string | Complete, opaque plugin options |
|
||||
| `SubscriptionRecord` | `url` | string | Complete subscription URL |
|
||||
Each store uses its own classic login Keychain Generic Password service:
|
||||
|
||||
These are ordinary string values inside the decrypted JSON, protected by encryption of the complete payload. The writer does not replace them with `passwordRef`, `pluginOptionsRef`, or `urlRef`, and there is no reference-set consistency check. LAN listener authentication credentials remain outside the server library.
|
||||
|
||||
`ServerFields` is the associated value of `CatalogEntry.Kind.server`; its JSON is nested under the node's `kind.server._0` object. A subscription's `url` is directly inside its `subscriptions` array record. Optional fields are omitted when nil by the synthesized Codable encoder. The corresponding Swift Codable models define the complete encoding of nodes, subscriptions, enum fields, and optional metadata; this document does not duplicate the full catalog schema.
|
||||
|
||||
## 5. Read Order and Errors
|
||||
|
||||
For an existing `catalog.enc`, the reader checks the file in this order:
|
||||
|
||||
1. Check the file size.
|
||||
2. Validate magic.
|
||||
3. Validate envelopeVersion.
|
||||
4. Parse keyID.
|
||||
5. Use the cached key when its ID matches, or read the corresponding key from Keychain; verify its length is 32 bytes.
|
||||
6. Authenticate and decrypt `combined`, using the original header as AAD.
|
||||
7. Decode the catalog JSON into the current payload model.
|
||||
8. Require schema v7, then validate unique node identities, catalog structure, subscription identities and fixed-group associations, and favorites.
|
||||
|
||||
The current limit is `P ≤ 64 × 1024 × 1024`, or 67,108,864 bytes. It applies to the complete catalog JSON, including the encoded password, plugin option, and URL values. There is no nested Base64 overhead.
|
||||
|
||||
The file size check accepts **73–67,108,937 bytes**. The 73-byte minimum is only a structural check. An empty payload is not valid JSON, so a 73-byte file cannot represent a valid empty library.
|
||||
|
||||
| Condition | Current Error |
|
||||
| File | Service |
|
||||
| --- | --- |
|
||||
| Size out of range, invalid magic, or invalid UUID | `ServerLibraryError.malformedFile` |
|
||||
| envelopeVersion is not `0x02` | `ServerLibraryError.unsupportedVersion` |
|
||||
| Key missing or not 32 bytes long | `ServerLibraryError.keyUnavailable` |
|
||||
| GCM authentication or SealedBox parsing fails | `ServerLibraryError.authenticationFailed` |
|
||||
| Catalog JSON decoding, schema version, or catalog structure validation fails | `CatalogFileStore.PersistenceError.corrupt` |
|
||||
| Keychain denies access or requires interactive authorization | `CredentialStoreError.keychainStatus`, preserving the system status code |
|
||||
| The file disappears after this storage instance loaded it, or a save is attempted without a successfully loaded or reset library | `ServerLibraryError.notLoaded` |
|
||||
| `catalog.age` | `com.qiuyuzhou.ShadowsocksX-NG2.server-library.age` |
|
||||
| `proxy-listening.age` | `com.qiuyuzhou.ShadowsocksX-NG2.proxy-listening.age` |
|
||||
|
||||
File system read errors propagate as their original errors. If multiple conditions fail, the reader reports the first error encountered in its read order. Codable decoding precedes the schema version check, so an incompatible payload can fail decoding before its version is checked.
|
||||
Each account is a UUID; its value is the UTF-8 standard `AGE-SECRET-KEY-1...` identity, not raw AES bytes. Items do not synchronize and do not use the Data Protection Keychain. Creation grants access only to the current application through a classic Keychain ACL.
|
||||
|
||||
When `catalog.enc` exists, a read failure does not trigger fallback to `catalog.json`, plaintext decoding, or automatic empty-library creation. The application blocks library writes and preserves the existing proxy runtime. Overwriting the library requires explicit user confirmation to discard the existing configuration.
|
||||
The file contains no Keychain account locator. The reader enumerates accounts in the relevant service and tries identities until one decrypts the file. Inaccessible or unrelated candidates do not prevent an accessible matching identity from succeeding. Normal saves reuse the matched identity, cached in the current process. A confirmed reset stores and reads back a new identity before atomically replacing ciphertext; older identities remain available if the commit fails or the process exits midway. Records therefore grow with resets. Identity cleanup and manual rotation are not provided.
|
||||
|
||||
## 6. Writing and Historical Formats
|
||||
Startup/background reads do not request authorization UI. Existing explicit application interaction controls remain in force. Losing a matching identity prevents decryption; changing the file format does not bypass Keychain authorization or provide independent recovery.
|
||||
|
||||
The write sequence is: encode the complete catalog JSON → encrypt in memory → write an encrypted temporary file → atomically replace the target. There is no separate secret collection or credential write journal for the library. Workflow changes use a complete working value; the application publishes the new committed value only after persistence succeeds.
|
||||
## Inspect with standard tools
|
||||
|
||||
The directory permission is `0700`. Temporary files are created with permission `0600`. Temporary files reside in the target directory and use the name `.catalog.enc.tmp-<UUID>`. They contain the complete encrypted format too. The current writer does not explicitly call `fsync`; this document does not promise durability through power loss.
|
||||
Install the reference CLI with `brew install age`. In Keychain Access, search for the exact service above and inspect the account UUIDs. Use the system `security` tool to retrieve an identity after system authorization. Replace `ACCOUNT_UUID`; repeat with other accounts into the same identity file if the service contains several identities.
|
||||
|
||||
NG2 has not shipped, so the reader does not migrate historical NG2 data:
|
||||
```bash
|
||||
umask 077
|
||||
workdir=$(mktemp -d)
|
||||
trap 'rm -rf "$workdir"' EXIT
|
||||
security find-generic-password \
|
||||
-s com.qiuyuzhou.ShadowsocksX-NG2.server-library.age \
|
||||
-a ACCOUNT_UUID -w >> "$workdir/identities.txt"
|
||||
age --decrypt -i "$workdir/identities.txt" \
|
||||
"$HOME/Library/Application Support/ShadowsocksX-NG2/configuration/catalog.age" | jq .
|
||||
```
|
||||
|
||||
- An existing envelope v1 file is rejected as `ServerLibraryError.unsupportedVersion`; catalog schemas v1–v6 are not supported.
|
||||
- If `catalog.enc` is absent but `catalog.json` exists, startup returns `ServerLibraryError.unsupportedVersion`. It checks only the old file's existence; it does not read its content, resolve old per-item Keychain credentials, or delete either.
|
||||
- A fresh storage instance creates an encrypted empty library only when neither library file exists. It stores and reads back the new master key before committing ciphertext.
|
||||
- A user-confirmed reset writes a new empty `catalog.enc` with a new master key. It does not delete the old `catalog.json` or old per-item Keychain credentials. The current key and committed application state change only after the new file is successfully saved.
|
||||
For listening configuration, substitute its service and `proxy-listening.age`. Choose one-time authorization when prompted; do not broaden the application's ACL. The identity file is a secret and the output contains configuration credentials. These commands deliberately export a temporary identity for maintenance; the application never writes identities to ordinary files, logs or diagnostic reports. Removal does not promise secure erasure.
|
||||
|
||||
The user-facing import from the old ShadowsocksX-NG application remains available. It constructs new records carrying their secret values; it is distinct from migration of NG2's historical storage formats.
|
||||
Standard age tools can also encrypt JSON to the corresponding `age1...` recipient. Such a file still has to satisfy the application's schema and size checks. Quit the GUI before manually replacing a persistent configuration, since the running app holds committed data in memory. No application-specific decryptor or private-key export UI is required.
|
||||
|
||||
## 7. Format Limitations
|
||||
## Reads, writes and recovery
|
||||
|
||||
- The header, file size, and file system timestamps remain visible.
|
||||
- Authentication detects tampering, but this format has no independent rollback protection state.
|
||||
- After authorized access, the current application process retains the master key and decrypted data until exit.
|
||||
- This is not a password-encrypted backup format that works independently of the local keychain.
|
||||
Reads limit encoded file size before decryption, accounting for age chunk tags, header allowance and armor overhead; plaintext is checked against its original limit after decryption. Invalid age data, missing identities, authentication failures and invalid JSON fail loading. There is no plaintext fallback or automatic replacement with defaults.
|
||||
|
||||
## 8. Source Entry Points
|
||||
Writes encode and validate the entire value, encrypt in memory, then atomically replace the destination with a ciphertext-only temporary file in the same directory. Directories use `0700`; temporary and destination files use `0600`. The current writer does not explicitly `fsync`, so atomic replacement does not promise power-loss durability. Application state is published only after successful persistence.
|
||||
|
||||
- [ServerLibraryStorage.swift](../ShadowsocksX-NG2/Domain/Catalog/ServerLibraryStorage.swift): header, encryption, decryption, payload size limits, and historical-file handling.
|
||||
- [CatalogFileStore.swift](../ShadowsocksX-NG2/Domain/Catalog/CatalogFileStore.swift): direct catalog JSON encoding, schema v7, and structure validation.
|
||||
- [MasterKeyStore.swift](../ShadowsocksX-NG2/Domain/Credentials/MasterKeyStore.swift): master key attributes and keychain access.
|
||||
- [CatalogModels.swift](../ShadowsocksX-NG2/Domain/Catalog/CatalogModels.swift) and [SubscriptionRecord.swift](../ShadowsocksX-NG2/Domain/Subscription/SubscriptionRecord.swift): Codable models for catalog records and inline secrets.
|
||||
- [AtomicFileWriter.swift](../ShadowsocksX-NG2/Domain/Runtime/AtomicFileWriter.swift): temporary files and permission baseline.
|
||||
The `.age` file takes precedence when present. If absent, an existing corresponding `.enc` or legacy `.json` file causes an unsupported-format error, not fresh initialization. NG2 historical formats are not migrated, read, deleted or used as fallback; old AES and per-item Keychain records are not accessed or cleaned up. A confirmed reset creates the new `.age` file and leaves historical files untouched.
|
||||
|
||||
The server library retains retry/unlock and confirmed empty-library creation. Listening configuration retains its startup gate and confirmed reset to defaults with LAN listening disabled. Loading failure or failed reset preserves existing files and running proxy/system-proxy state. The user-initiated legacy ShadowsocksX-NG import remains available.
|
||||
|
||||
## Verification and limitations
|
||||
|
||||
`AgeConfigurationStorageTests` exercises standard age CLI decryption and encryption in binary and armor for both stores, historical-file rejection, candidate identity selection, failed reset commits, schema/size checks and an isolated real Keychain. The test requires `age` on PATH or in a Homebrew bin directory and an accessible login Keychain.
|
||||
|
||||
age protects stored content; file size and timestamps remain visible. It does not provide rollback protection, safe memory erasure, cross-machine identity recovery or an independent password-encrypted backup. The application retains decrypted values and the matching identity until exit.
|
||||
|
||||
Source entry points: [EncryptedFileStorage](../ShadowsocksX-NG2/Domain/Credentials/EncryptedFileStorage.swift), [MasterKeyStore](../ShadowsocksX-NG2/Domain/Credentials/MasterKeyStore.swift), [CatalogFileStore](../ShadowsocksX-NG2/Domain/Catalog/CatalogFileStore.swift), [ProxyListeningFileStore](../ShadowsocksX-NG2/Domain/Settings/ProxyListeningFileStore.swift), [AtomicFileWriter](../ShadowsocksX-NG2/Domain/Runtime/AtomicFileWriter.swift).
|
||||
|
||||
@@ -1,91 +1,23 @@
|
||||
# 服务器库加密文件格式 v2
|
||||
# 使用 age 加密敏感配置
|
||||
|
||||
[English](server-library-encryption-format.md) | 简体中文
|
||||
|
||||
记录日期:2026-10-18。实现快照:`507917a`。
|
||||
服务器库和代理监听配置通过 [AgeSwift](https://github.com/qiuyuzhou/AgeSwift) 使用标准 [age v1 文件格式](https://age-encryption.org/v1)。采用理由与恢复边界见 [ADR-0050](adr/0050-age-encrypted-configuration-storage.md)。
|
||||
|
||||
本文记录该提交中的 `catalog.enc` 格式,供维护、排错和编写兼容读取器时参考。本文属于实现时点的格式说明,不持续维护为最新现状;当前行为以源码和 [ADR-0035](adr/0035-encrypt-persistent-server-library.md) 为准。
|
||||
## 文件与载荷
|
||||
|
||||
## 1. 文件与保护范围
|
||||
两个文件均位于 `~/Library/Application Support/ShadowsocksX-NG2/configuration/`:
|
||||
|
||||
默认路径为 `~/Library/Application Support/ShadowsocksX-NG2/configuration/catalog.enc`。文件是二进制,结构为:
|
||||
|
||||
```text
|
||||
完整文件 = 明文文件头 || nonce || ciphertext || tag
|
||||
认证附加数据 AAD = 明文文件头的原始 45 字节
|
||||
加密算法 = AES-256-GCM
|
||||
```
|
||||
|
||||
`||` 表示按字节拼接。文件头不加密,但参与认证。文件内没有主密钥。
|
||||
|
||||
密文包含完整配置目录、订阅记录、有序收藏,以及由所属记录直接保存的密码、完整插件参数和订阅 URL。运行配置 `sslocal-active.json`、监听认证派生文件、用户主动生成的分享或导出文件不属于此格式。
|
||||
|
||||
## 2. 二进制布局
|
||||
|
||||
偏移从 0 开始,单位均为字节。令 `P` 为加密前完整 payload 的 UTF-8 字节数;AES-GCM 的 ciphertext 长度也为 `P`。
|
||||
|
||||
| 偏移 | 长度 | 字段 | 编码与含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| 0 | 8 | magic | ASCII `SSXNG2LB`,十六进制为 `53 53 58 4E 47 32 4C 42` |
|
||||
| 8 | 1 | envelopeVersion | 固定为二进制 `0x02`,不是字符 `2` |
|
||||
| 9 | 36 | keyID | UUID 的 UTF-8 文本,含连字符,不含引号、结束零字节或换行 |
|
||||
| 45 | 12 | nonce | CryptoKit 默认生成的随机 nonce |
|
||||
| 57 | `P` | ciphertext | 完整 payload 的 AES-GCM 密文 |
|
||||
| `57 + P` | 16 | tag | GCM 认证标签 |
|
||||
|
||||
因此:
|
||||
|
||||
```text
|
||||
headerLength = 8 + 1 + 36 = 45
|
||||
combined = nonce || ciphertext || tag
|
||||
fileLength = P + 73
|
||||
P = fileLength - 73
|
||||
```
|
||||
|
||||
文件没有额外长度字段、填充、分隔符、压缩层或 Base64 外壳。`keyID` 示例为 `65A4F8AE-28F6-46A2-B61F-7CFA9A85D7F2`;写入器使用 `UUID.uuidString` 生成文本,不能把它替换成 UUID 的 16 字节二进制表示。
|
||||
|
||||
v2 没有独立算法字段。版本 `0x02` 固定对应 AES-256-GCM;读取器不在失败后尝试另一种算法。
|
||||
|
||||
## 3. 主密钥与认证
|
||||
|
||||
主密钥由 `SymmetricKey(size: .bits256)` 随机生成,长度为 32 字节。它不是用户口令,也不是由 `keyID` 派生的值。
|
||||
|
||||
生产存储使用经典钥匙串的 Generic Password 项:
|
||||
|
||||
| 属性 | 值 |
|
||||
| --- | --- |
|
||||
| `kSecClass` | `kSecClassGenericPassword` |
|
||||
| `kSecAttrService` | `com.qiuyuzhou.ShadowsocksX-NG2.server-library` |
|
||||
| `kSecAttrAccount` | 文件头 UUID 解析后的 `uuidString` |
|
||||
| `kSecValueData` | 主密钥原始 32 字节,不是十六进制或 Base64 文本 |
|
||||
| `kSecAttrSynchronizable` | `false` |
|
||||
| `kSecUseDataProtectionKeychain` | `false` |
|
||||
|
||||
创建项时,`SecAccessCreate` 的获准应用列表只包含当前应用。文件头只负责定位密钥,不授予钥匙串访问权限。只有 `catalog.enc` 而没有对应主密钥时,无法解密服务器库。
|
||||
|
||||
写入器使用 CryptoKit 默认 nonce,每次保存重新生成。主密钥在正常保存时保持不变;用户确认重置服务器库时生成新主密钥。nonce 和 tag 直接使用 `AES.GCM.SealedBox.combined` 的布局。
|
||||
|
||||
加密和解密都将原始头字节作为 AAD。兼容读取器必须使用文件中的原始 45 字节,不能解析 UUID 后重新编码文件头来替代 AAD。
|
||||
|
||||
## 4. 解密后的 payload
|
||||
|
||||
解密结果直接是目录的 UTF-8 JSON。没有外层 `catalog`/`secrets` 对象、嵌套的 Base64 目录或独立凭据引用映射。
|
||||
|
||||
写入器生成目录 schema v7,读取器只接受 v7。该 schema 版本与文件头的 envelopeVersion 独立:`0x02` 不等于目录 schema v2。
|
||||
|
||||
### 4.1 目录 JSON
|
||||
|
||||
五个顶层字段均为必需字段,空数组也不能省略:
|
||||
|
||||
| 字段 | JSON 类型 | 含义 |
|
||||
| 文件 | 解密后的 UTF-8 JSON | 明文大小上限 |
|
||||
| --- | --- | --- |
|
||||
| `version` | number | 目录 schema 版本,固定为 `7` |
|
||||
| `rootChildren` | array | 根层节点身份的有序数组 |
|
||||
| `entries` | array | 全部节点;写入时按节点 `id.rawValue` 排序 |
|
||||
| `subscriptions` | array | 订阅记录,包含 URL 值与刷新资料 |
|
||||
| `favoriteIDs` | array | 收藏节点身份的有序数组 |
|
||||
| `catalog.age` | 目录 schema v7:`version`、`rootChildren`、`entries`、`subscriptions`、`favoriteIDs` | 64 MiB |
|
||||
| `proxy-listening.age` | `{ "version": 1, "configuration": { "local": …, "lan": … } }` | 1 MiB |
|
||||
|
||||
空库的 payload 如下:
|
||||
服务器库包含完整配置目录、订阅与有序收藏;服务器密码、完整插件参数、订阅 URL 直接保存在所属记录中。监听认证资料属于独立监听配置。运行 JSON、LAN 认证文件、用户主动导出与分享不属于本格式的范围。
|
||||
|
||||
保存使用单个 X25519 recipient 的二进制 age v1,不增加应用文件头、UUID 前缀、旁路索引、外层 Base64 或自研密码容器。读取也接受标准 ASCII armor;再次保存统一写二进制。JSON 结构版本位于加密载荷中,与 age 格式版本独立。完整模型通过校验后才发布。
|
||||
|
||||
空服务器库的载荷为:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -97,77 +29,56 @@ v2 没有独立算法字段。版本 `0x02` 固定对应 AES-256-GCM;读取器
|
||||
}
|
||||
```
|
||||
|
||||
节点的层级和显示顺序由 `rootChildren` 与配置组的子节点数组表达,不能用 `entries` 数组顺序替代。写入器启用 `prettyPrinted` 和 `sortedKeys`;兼容读取器不能依赖空白或对象键顺序。
|
||||
目录层级与顺序以 `rootChildren` 和分组子节点数组为准。秘密是记录中的普通值,不是凭据引用。完整结构以 Codable 模型为准,空白与对象键顺序不影响语义。
|
||||
|
||||
### 4.2 秘密由记录直接持有
|
||||
## Keychain identity
|
||||
|
||||
| 所属模型 | 字段 | JSON 类型 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `ServerFields` | `password` | string | 服务器密码 |
|
||||
| `ServerFields` | `pluginOptions` | optional string | 完整、不解析内容的插件参数 |
|
||||
| `SubscriptionRecord` | `url` | string | 完整订阅 URL |
|
||||
两个存储分别使用经典登录钥匙串中的 Generic Password service:
|
||||
|
||||
这些字段在解密后的 JSON 中是普通字符串,由整个 payload 的加密提供保护。写入器不再用 `passwordRef`、`pluginOptionsRef` 或 `urlRef` 代替它们,也不再校验引用集合。局域网监听认证凭据仍在服务器库范围之外。
|
||||
|
||||
`ServerFields` 是 `CatalogEntry.Kind.server` 的关联值,其 JSON 位于节点的 `kind.server._0` 对象中。订阅 `url` 直接位于 `subscriptions` 数组的记录内。合成的 Codable 编码器在可选字段为 nil 时省略该字段。节点、订阅、枚举字段和可选元数据的完整编码以对应 Swift Codable 模型为准,本文不另行复制整个目录 schema。
|
||||
|
||||
## 5. 读取顺序与错误
|
||||
|
||||
存在 `catalog.enc` 时,读取器按以下顺序检查:
|
||||
|
||||
1. 检查文件大小。
|
||||
2. 校验 magic。
|
||||
3. 校验 envelopeVersion。
|
||||
4. 解析 keyID。
|
||||
5. ID 匹配时使用缓存的主密钥,否则从 Keychain 读取对应密钥;检查长度为 32 字节。
|
||||
6. 用原始文件头作为 AAD,验证并解密 `combined`。
|
||||
7. 按当前 payload 模型解码目录 JSON。
|
||||
8. 要求 schema 为 v7,再校验节点身份唯一性、目录结构、订阅身份与固定分组关联,以及收藏。
|
||||
|
||||
当前限制 `P ≤ 64 × 1024 × 1024`,即最多 67,108,864 字节。该限制针对完整目录 JSON,包含密码、插件参数和 URL 值的编码开销,没有嵌套 Base64 的额外开销。
|
||||
|
||||
文件大小检查区间为 **73–67,108,937 字节**。73 字节只是结构检查的下限;空 payload 不是合法 JSON,因此不能据此构造有效空库。
|
||||
|
||||
| 情况 | 当前错误 |
|
||||
| 文件 | Service |
|
||||
| --- | --- |
|
||||
| 大小越界、magic 无效或 UUID 无效 | `ServerLibraryError.malformedFile` |
|
||||
| envelopeVersion 不是 `0x02` | `ServerLibraryError.unsupportedVersion` |
|
||||
| 找不到密钥,或密钥不是 32 字节 | `ServerLibraryError.keyUnavailable` |
|
||||
| GCM 认证或 SealedBox 解析失败 | `ServerLibraryError.authenticationFailed` |
|
||||
| 目录 JSON 解码、schema 版本或目录结构校验失败 | `CatalogFileStore.PersistenceError.corrupt` |
|
||||
| 钥匙串拒绝访问或需要交互授权 | `CredentialStoreError.keychainStatus`,保留系统状态码 |
|
||||
| 当前存储实例加载后文件消失,或未成功加载、重置服务器库就尝试保存 | `ServerLibraryError.notLoaded` |
|
||||
| `catalog.age` | `com.qiuyuzhou.ShadowsocksX-NG2.server-library.age` |
|
||||
| `proxy-listening.age` | `com.qiuyuzhou.ShadowsocksX-NG2.proxy-listening.age` |
|
||||
|
||||
文件系统读取错误按原错误抛出。多个条件同时不满足时,以读取顺序最先遇到的错误为准。Codable 解码先于 schema 版本检查,因此不兼容的 payload 可能在检查版本之前就解码失败。
|
||||
每条记录的 account 是 UUID,值为 UTF-8 编码的标准 `AGE-SECRET-KEY-1...` identity 文本,不是 AES 原始字节。记录不同步、不使用 Data Protection Keychain;创建时通过经典 Keychain ACL 仅授权当前应用。
|
||||
|
||||
存在 `catalog.enc` 时,即使读取失败,也不自动退回 `catalog.json`、尝试明文解码或创建空库。应用阻止服务器库写入,保留已有代理运行。只有用户明确确认放弃原配置后,才执行覆盖重置。
|
||||
文件不包含 Keychain account 定位信息。读取器枚举对应 service 的 account,尝试 identity 直至成功。存在无法访问或不匹配的候选时,其他可访问且匹配的 identity 仍能正常解密。正常保存复用匹配的 identity,并在当前进程缓存。确认重置时,先保存并回读新 identity,再原子替换密文;保留旧 identity,使提交失败或进程中断时旧文件仍可读取。记录随重置次数增加,不提供自动清理或手动轮换。
|
||||
|
||||
## 6. 写入与历史格式
|
||||
启动和后台读取不请求系统授权弹窗,继续沿用既有显式交互入口。丢失匹配 identity 后无法解密;更换格式不绕过 Keychain 授权,也不提供独立恢复能力。
|
||||
|
||||
写入顺序为:完整目录 JSON 编码 → 内存加密 → 写入密文临时文件 → 原子替换。服务器库不再单独收集秘密,也不执行逐项凭据写入日志。工作流使用完整工作副本,只有持久化成功后才发布新的已提交值。
|
||||
## 使用标准工具检查
|
||||
|
||||
目录权限为 `0700`,临时文件创建时的权限为 `0600`。临时文件与目标文件位于同一目录,名称采用 `.catalog.enc.tmp-<UUID>`;临时文件也只包含完整的加密格式。当前写入器没有显式 `fsync`,本文不承诺断电持久性。
|
||||
通过 `brew install age` 安装参考 CLI。在“钥匙串访问”中搜索上表完整 service,查看 account UUID。使用系统 `security` 工具,经系统授权后取得 identity。将 `ACCOUNT_UUID` 替换为实际账户;有多条记录时,可重复执行导出命令,把其他 identity 追加到同一个文件。
|
||||
|
||||
NG2 尚未发版,读取器不迁移其历史数据:
|
||||
```bash
|
||||
umask 077
|
||||
workdir=$(mktemp -d)
|
||||
trap 'rm -rf "$workdir"' EXIT
|
||||
security find-generic-password \
|
||||
-s com.qiuyuzhou.ShadowsocksX-NG2.server-library.age \
|
||||
-a ACCOUNT_UUID -w >> "$workdir/identities.txt"
|
||||
age --decrypt -i "$workdir/identities.txt" \
|
||||
"$HOME/Library/Application Support/ShadowsocksX-NG2/configuration/catalog.age" | jq .
|
||||
```
|
||||
|
||||
- 已有封装 v1 文件返回 `ServerLibraryError.unsupportedVersion`;目录 schema v1–v6 不再支持。
|
||||
- 没有 `catalog.enc` 但存在 `catalog.json` 时,启动返回 `ServerLibraryError.unsupportedVersion`。只检查旧文件是否存在,不读取其内容,不解析旧逐项 Keychain 凭据,也不清理这些文件或凭据。
|
||||
- 新存储实例仅在两种库文件均不存在时创建加密空库;提交密文前先保存并读回校验新主密钥。
|
||||
- 用户确认重置时,用新主密钥写入新的空 `catalog.enc`,不删除旧 `catalog.json` 或旧逐项 Keychain 凭据。只有新文件保存成功后,才替换当前主密钥与应用已提交状态。
|
||||
检查监听配置时,替换 service 和文件名为监听对应值。系统提示时选择单次授权,不放宽应用 ACL。identity 文件属于秘密,输出包含配置凭据。这些命令是维护者主动导出临时 identity 的调试操作;应用自身不向普通文件、日志或诊断报告写出 identity。删除临时文件不代表安全擦除。
|
||||
|
||||
保留用户主动执行的旧版 ShadowsocksX-NG 服务器导入。该功能构造直接携带秘密值的新记录,与 NG2 历史存储格式迁移不同。
|
||||
标准 age 工具也能用对应的 `age1...` recipient 加密 JSON;应用仍会检查结构和大小。手动替换持久配置前退出 GUI,避免运行中的应用使用内存已提交值覆盖人工修改。无需项目专用解密器或应用内私钥导出界面。
|
||||
|
||||
## 7. 格式能力的边界
|
||||
## 读取、提交与恢复
|
||||
|
||||
- 文件头、文件大小和文件系统时间仍然可见。
|
||||
- 认证能检测文件篡改,但该格式没有独立的防回滚状态。
|
||||
- 主密钥和解密资料在获准读取后保留于当前应用进程,直到退出。
|
||||
- 此格式不是可脱离本机钥匙串使用的密码加密备份格式。
|
||||
读取前限制文件大小,计入 age 分块标签、文件头余量和 armor 开销;解密后再次检查明文上限。age 格式错误、identity 不可用、认证失败或 JSON 校验失败都会导致加载失败,不回退明文,不自动用默认值覆盖。
|
||||
|
||||
## 8. 源码入口
|
||||
保存先编码并校验完整值,再在内存加密,使用同目录的纯密文临时文件原子替换目标。目录权限为 `0700`,临时文件和目标文件为 `0600`。当前写入器不显式调用 `fsync`,原子替换不保证断电持久性。成功保存后才发布应用内存状态。
|
||||
|
||||
- [ServerLibraryStorage.swift](../ShadowsocksX-NG2/Domain/Catalog/ServerLibraryStorage.swift):文件头、加解密、payload 大小限制与历史文件处理。
|
||||
- [CatalogFileStore.swift](../ShadowsocksX-NG2/Domain/Catalog/CatalogFileStore.swift):直接目录 JSON 编码、schema v7 和结构校验。
|
||||
- [MasterKeyStore.swift](../ShadowsocksX-NG2/Domain/Credentials/MasterKeyStore.swift):主密钥的 Keychain 属性与访问方式。
|
||||
- [CatalogModels.swift](../ShadowsocksX-NG2/Domain/Catalog/CatalogModels.swift)、[SubscriptionRecord.swift](../ShadowsocksX-NG2/Domain/Subscription/SubscriptionRecord.swift):目录记录及内联秘密的 Codable 模型。
|
||||
- [AtomicFileWriter.swift](../ShadowsocksX-NG2/Domain/Runtime/AtomicFileWriter.swift):临时文件与权限基线。
|
||||
新 `.age` 文件存在时优先使用它;不存在但对应 `.enc` 或历史 `.json` 存在时,报告不支持的格式,不按全新安装初始化。不迁移、读取、删除或回退到历史 NG2 文件,不访问或清理旧 AES 与逐项 Keychain 凭据。用户确认重置后创建新的 `.age` 文件,旧文件保持原样。
|
||||
|
||||
服务器库保留重试、显式解锁和确认创建空库;监听配置保留启动门禁和确认重置,默认关闭局域网监听。加载失败或重置失败期间保留原文件、已有后台代理与系统代理。用户主动执行的旧版 ShadowsocksX-NG 导入仍保留。
|
||||
|
||||
## 验证与限制
|
||||
|
||||
`AgeConfigurationStorageTests` 覆盖两份配置与标准 age CLI 的二进制、armor 双向互操作,以及旧文件拒绝、候选 identity 匹配、重置提交失败、结构和大小检查、隔离的真实 Keychain 操作。运行该测试需要 PATH 或 Homebrew bin 目录内存在 `age`,并能够访问登录钥匙串。
|
||||
|
||||
age 保护静态内容,文件大小与时间戳仍可见。它不提供回滚保护、安全内存擦除、跨机 identity 恢复或独立密码加密备份。应用持有已解密资料和匹配 identity 至退出。
|
||||
|
||||
源码入口:[EncryptedFileStorage](../ShadowsocksX-NG2/Domain/Credentials/EncryptedFileStorage.swift)、[MasterKeyStore](../ShadowsocksX-NG2/Domain/Credentials/MasterKeyStore.swift)、[CatalogFileStore](../ShadowsocksX-NG2/Domain/Catalog/CatalogFileStore.swift)、[ProxyListeningFileStore](../ShadowsocksX-NG2/Domain/Settings/ProxyListeningFileStore.swift)、[AtomicFileWriter](../ShadowsocksX-NG2/Domain/Runtime/AtomicFileWriter.swift)。
|
||||
|
||||
Reference in New Issue
Block a user