docs(ng2): update server library encryption format to v2

This commit is contained in:
Qiu Yuzhou
2026-10-18 18:07:32 +08:00
parent 507917ab67
commit f944133977
2 changed files with 112 additions and 146 deletions
+56 -73
View File
@@ -1,8 +1,8 @@
# Server Library Encryption File Format v1
# Server Library Encryption File Format v2
English | [简体中文](server-library-encryption-format.zh-CN.md)
Recorded on 2026-10-18. Implementation snapshot: `6cbef4e`.
Recorded on 2026-10-18. Implementation snapshot: `507917a`.
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.
@@ -18,7 +18,7 @@ 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, and their referenced passwords, complete plugin options, and subscription URLs. This format does not cover the runtime configuration `sslocal-active.json`, derived listener authentication files, or files that users explicitly share or export.
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
@@ -27,7 +27,7 @@ Offsets start at zero. All offsets and lengths are in bytes. Let `P` be the UTF-
| 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 `0x01`, not the character `1` |
| 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 |
@@ -44,7 +44,7 @@ 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 1 has no separate algorithm field. Version `0x01` specifies AES-256-GCM. The reader does not try a different algorithm after a failure.
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
@@ -69,110 +69,93 @@ Encryption and decryption both use the original header bytes as AAD. A compatibl
## 4. Decrypted Payload
Decryption produces UTF-8 JSON with two required fields:
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 |
| --- | --- | --- |
| `catalog` | string | The UTF-8 bytes of the catalog JSON, encoded as a Base64 string by Foundation `Data` Codable encoding |
| `secrets` | object | A mapping from credential reference strings to secret strings; an empty mapping is `{}` |
| `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 |
The decoding layers are:
```text
catalog.enc
└─ AES-GCM decryption → payload JSON
├─ catalog → Base64 decoding → catalog JSON
└─ secrets → credential reference to secret value mapping
```
The outer JSON does not embed the catalog as an object. Nor is `catalog` a JSON-escaped catalog string: it requires Base64 decoding first. Base64 serializes the nested bytes; it does not provide confidentiality.
The following example represents an empty library. To shorten the example, the catalog JSON encoded in `catalog` omits the indentation used by the actual writer.
An empty library has this payload:
```json
{
"catalog": "eyJlbnRyaWVzIjpbXSwiZmF2b3JpdGVJRHMiOltdLCJyb290Q2hpbGRyZW4iOltdLCJzdWJzY3JpcHRpb25zIjpbXSwidmVyc2lvbiI6Nn0=",
"secrets": {}
"entries": [],
"favoriteIDs": [],
"rootChildren": [],
"subscriptions": [],
"version": 7
}
```
Decoding `catalog` produces:
`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.
```json
{"entries":[],"favoriteIDs":[],"rootChildren":[],"subscriptions":[],"version":6}
```
### 4.2 Secrets Belong to Records
The outer JSON has no separate payload version field and no fixed key order or whitespace convention. Do not depend on the exact text layout produced by a particular encoding operation.
| 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 |
### 4.1 Inner Catalog JSON
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.
The current writer emits catalog schema v6. The reader accepts v1–v6. This schema version is independent of the header's envelopeVersion: `0x01` does not mean catalog schema v1.
Schema v6 has these top-level fields:
| Field | Meaning |
| --- | --- |
| `version` | Catalog schema version; the current writer emits `6` |
| `rootChildren` | An ordered array of root-level node identities |
| `entries` | All nodes, sorted by node `id.rawValue` when written |
| `subscriptions` | Subscription records, including URL credential references and refresh information |
| `favoriteIDs` | An ordered array of favorite node identities |
`rootChildren` and each configuration group's child array define hierarchy and display order. Do not substitute the order of the `entries` array. The inner writer enables `prettyPrinted` and `sortedKeys`. The corresponding Swift Codable models define the complete encoding of nodes, subscriptions, and enum fields. This document does not duplicate the full catalog schema.
### 4.2 Credential Reference Consistency
The keys in `secrets` must exactly match the set of credentials referenced by the catalog. The reader rejects both missing values and extra secret entries.
The reference set is the union of:
- Each server's `passwordRef`.
- Each server's non-nil `pluginOptionsRef`.
- Each subscription's `urlRef`.
Values are the original secret strings. The writer includes only values referenced by the current catalog, not other credentials remaining in process memory. This rule does not include LAN listener authentication credentials.
`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
The current reader checks the file in this order:
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. Find the corresponding master key and verify its length is 32 bytes.
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 outer payload JSON.
8. Base64-decode `catalog`, then decode and validate the catalog schema.
9. Verify that the credential reference set exactly matches the keys in `secrets`.
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.
`Data` Base64 decoding occurs as part of the outer Codable decoding. Step 8 above describes the data dependency.
The current limit is `P ≤ 64 × 1024 × 1024`, or 67,108,864 bytes. This limit applies to the complete payload JSON, including the Base64 string and the encoded `secrets` mapping. It is not a limit on the inner catalog JSON alone.
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 |
| --- | --- |
| Size out of range, invalid magic, invalid UUID, or invalid outer JSON or Base64 | `ServerLibraryError.malformedFile` |
| envelopeVersion is not `0x01` | `ServerLibraryError.unsupportedVersion` |
| 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 credential references do not match the keys in `secrets` | `ServerLibraryError.missingCredential` |
| Inner catalog JSON, schema, or catalog structure validation fails | `CatalogFileStore.PersistenceError.corrupt` |
| 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` |
File system read errors propagate as their original errors. If multiple conditions fail, the reader reports the first error encountered in its read order.
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.
When `catalog.enc` exists, a read failure does not trigger fallback to `catalog.json`, plaintext decoding, or automatic empty-library creation. The application overwrites the library only after the user explicitly confirms discarding the existing configuration.
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.
## 6. Writing and Migration
## 6. Writing and Historical Formats
The write sequence is: collect referenced secrets → encode the catalog → encode the outer JSON → encrypt in memory → write an encrypted temporary file → atomically replace the target.
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.
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.
If `catalog.enc` does not exist, startup reads the old `catalog.json` and resolves its required legacy credentials. After all reads succeed, it generates a master key, commits the encrypted file, and reopens it for comparison. After successful verification, it attempts to delete the old file and migrated credential items. On first launch with neither library file present, it creates a new encrypted empty library.
NG2 has not shipped, so the reader does not migrate historical NG2 data:
- 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.
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.
## 7. Format Limitations
@@ -183,8 +166,8 @@ If `catalog.enc` does not exist, startup reads the old `catalog.json` and resolv
## 8. Source Entry Points
- [ServerLibraryStorage.swift](../ShadowsocksX-NG2/Domain/Catalog/ServerLibraryStorage.swift): header, encryption, decryption, payload, size limits, and credential reference validation.
- [CatalogFileStore.swift](../ShadowsocksX-NG2/Domain/Catalog/CatalogFileStore.swift): catalog JSON encoding, schema versions, and structure validation.
- [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.
- [LibraryMasterKeyStore.swift](../ShadowsocksX-NG2/Domain/Credentials/LibraryMasterKeyStore.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 the inner catalog.
- [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.
+56 -73
View File
@@ -1,8 +1,8 @@
# 服务器库加密文件格式 v1
# 服务器库加密文件格式 v2
[English](server-library-encryption-format.md) | 简体中文
记录日期:2026-10-18。实现快照:`6cbef4e`。
记录日期:2026-10-18。实现快照:`507917a`。
本文记录该提交中的 `catalog.enc` 格式,供维护、排错和编写兼容读取器时参考。本文属于实现时点的格式说明,不持续维护为最新现状;当前行为以源码和 [ADR-0035](adr/0035-encrypt-persistent-server-library.md) 为准。
@@ -18,7 +18,7 @@
`||` 表示按字节拼接。文件头不加密,但参与认证。文件内没有主密钥。
密文包含完整配置目录、订阅记录、有序收藏,以及这些资料引用的密码、完整插件参数和订阅 URL。运行配置 `sslocal-active.json`、监听认证派生文件、用户主动生成的分享或导出文件不属于此格式。
密文包含完整配置目录、订阅记录、有序收藏,以及由所属记录直接保存的密码、完整插件参数和订阅 URL。运行配置 `sslocal-active.json`、监听认证派生文件、用户主动生成的分享或导出文件不属于此格式。
## 2. 二进制布局
@@ -27,7 +27,7 @@
| 偏移 | 长度 | 字段 | 编码与含义 |
| --- | --- | --- | --- |
| 0 | 8 | magic | ASCII `SSXNG2LB`,十六进制为 `53 53 58 4E 47 32 4C 42` |
| 8 | 1 | envelopeVersion | 固定为二进制 `0x01`,不是字符 `1` |
| 8 | 1 | envelopeVersion | 固定为二进制 `0x02`,不是字符 `2` |
| 9 | 36 | keyID | UUID 的 UTF-8 文本,含连字符,不含引号、结束零字节或换行 |
| 45 | 12 | nonce | CryptoKit 默认生成的随机 nonce |
| 57 | `P` | ciphertext | 完整 payload 的 AES-GCM 密文 |
@@ -44,7 +44,7 @@ P = fileLength - 73
文件没有额外长度字段、填充、分隔符、压缩层或 Base64 外壳。`keyID` 示例为 `65A4F8AE-28F6-46A2-B61F-7CFA9A85D7F2`;写入器使用 `UUID.uuidString` 生成文本,不能把它替换成 UUID 的 16 字节二进制表示。
v1 没有独立算法字段。版本 `0x01` 固定对应 AES-256-GCM;读取器不在失败后尝试另一种算法。
v2 没有独立算法字段。版本 `0x02` 固定对应 AES-256-GCM;读取器不在失败后尝试另一种算法。
## 3. 主密钥与认证
@@ -69,110 +69,93 @@ v1 没有独立算法字段。版本 `0x01` 固定对应 AES-256-GCM;读取器
## 4. 解密后的 payload
解密结果是 UTF-8 JSON,具有两个必需字段:
解密结果直接是目录的 UTF-8 JSON。没有外层 `catalog`/`secrets` 对象、嵌套的 Base64 目录或独立凭据引用映射。
写入器生成目录 schema v7,读取器只接受 v7。该 schema 版本与文件头的 envelopeVersion 独立:`0x02` 不等于目录 schema v2。
### 4.1 目录 JSON
五个顶层字段均为必需字段,空数组也不能省略:
| 字段 | JSON 类型 | 含义 |
| --- | --- | --- |
| `catalog` | string | 目录 JSON 的 UTF-8 字节,经 Foundation `Data` 的 Codable 规则编码为 Base64 字符串 |
| `secrets` | object | 凭据引用字符串到秘密字符串的映射;空映射为 `{}` |
| `version` | number | 目录 schema 版本,固定为 `7` |
| `rootChildren` | array | 根层节点身份的有序数组 |
| `entries` | array | 全部节点;写入时按节点 `id.rawValue` 排序 |
| `subscriptions` | array | 订阅记录,包含 URL 值与刷新资料 |
| `favoriteIDs` | array | 收藏节点身份的有序数组 |
处理层次为:
```text
catalog.enc
└─ AES-GCM 解密 → payload JSON
├─ catalog → Base64 解码 → 目录 JSON
└─ secrets → 凭据引用到秘密值的映射
```
外层 JSON 不是直接嵌入目录对象。`catalog` 也不是经过 JSON 转义的目录文本;它必须先做 Base64 解码。这里的 Base64 仅用于序列化嵌套字节,不承担保密作用。
空库的示意 payload 如下。为缩短示例,其 `catalog` 对应的目录 JSON 省略了实际写入器的缩进。
空库的 payload 如下:
```json
{
"catalog": "eyJlbnRyaWVzIjpbXSwiZmF2b3JpdGVJRHMiOltdLCJyb290Q2hpbGRyZW4iOltdLCJzdWJzY3JpcHRpb25zIjpbXSwidmVyc2lvbiI6Nn0=",
"secrets": {}
"entries": [],
"favoriteIDs": [],
"rootChildren": [],
"subscriptions": [],
"version": 7
}
```
其中 `catalog` 解码后为:
节点的层级和显示顺序由 `rootChildren` 与配置组的子节点数组表达,不能用 `entries` 数组顺序替代。写入器启用 `prettyPrinted` 和 `sortedKeys`;兼容读取器不能依赖空白或对象键顺序。
```json
{"entries":[],"favoriteIDs":[],"rootChildren":[],"subscriptions":[],"version":6}
```
### 4.2 秘密由记录直接持有
外层 JSON 没有独立的 payload 版本字段,也没有固定键顺序或空白约定。不要依赖某次 JSON 编码产生的确切文本布局。
| 所属模型 | 字段 | JSON 类型 | 含义 |
| --- | --- | --- | --- |
| `ServerFields` | `password` | string | 服务器密码 |
| `ServerFields` | `pluginOptions` | optional string | 完整、不解析内容的插件参数 |
| `SubscriptionRecord` | `url` | string | 完整订阅 URL |
### 4.1 内层目录 JSON
这些字段在解密后的 JSON 中是普通字符串,由整个 payload 的加密提供保护。写入器不再用 `passwordRef`、`pluginOptionsRef` 或 `urlRef` 代替它们,也不再校验引用集合。局域网监听认证凭据仍在服务器库范围之外。
当前写入目录 schema v6,读取兼容 v1–v6。该 schema 版本与文件头的 envelopeVersion 独立:`0x01` 不等于目录 schema v1。
v6 的顶层字段为:
| 字段 | 含义 |
| --- | --- |
| `version` | 目录 schema 版本,当前写入 `6` |
| `rootChildren` | 根层节点身份的有序数组 |
| `entries` | 全部节点;写入时按节点 `id.rawValue` 排序 |
| `subscriptions` | 订阅记录,包含 URL 凭据引用与刷新资料 |
| `favoriteIDs` | 收藏节点身份的有序数组 |
节点的层级和显示顺序由 `rootChildren` 与配置组的子节点数组表达,不能用 `entries` 数组顺序替代。内层写入器启用 `prettyPrinted` 和 `sortedKeys`。节点、订阅与枚举字段的完整编码以对应 Swift Codable 模型为准,本文不另行复制整个目录 schema。
### 4.2 凭据引用的一致性
`secrets` 的键集合必须与目录实际引用的凭据集合完全相等。读取器同时拒绝缺少引用值和多余的秘密项。
引用集合是以下字段的并集:
- 每个服务器的 `passwordRef`。
- 每个服务器非空的 `pluginOptionsRef`。
- 每条订阅的 `urlRef`。
值是原始秘密字符串。写入器只保存当前目录引用的值,不把进程内残留的其他凭据加入 payload。该规则不包含局域网监听认证凭据。
`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. 查找对应主密钥,并检查长度为 32 字节。
5. ID 匹配时使用缓存的主密钥,否则从 Keychain 读取对应密钥;检查长度为 32 字节。
6. 用原始文件头作为 AAD,验证并解密 `combined`。
7. 解码外层 payload JSON。
8. Base64 解码 `catalog`,再按目录 schema 解码和校验。
9. 检查凭据引用集合与 `secrets` 键集合相等。
7. 按当前 payload 模型解码目录 JSON。
8. 要求 schema 为 v7,再校验节点身份唯一性、目录结构、订阅身份与固定分组关联,以及收藏。
`Data` 的 Base64 解码属于外层 Codable 解码的一部分;上面的第 8 步用于说明数据依赖关系。
当前限制 `P ≤ 64 × 1024 × 1024`,即最多 67,108,864 字节。该限制针对完整 payload JSON,包含 Base64 字符串与 `secrets` 的编码开销,不是内层目录 JSON 的大小。
当前限制 `P ≤ 64 × 1024 × 1024`,即最多 67,108,864 字节。该限制针对完整目录 JSON,包含密码、插件参数和 URL 值的编码开销,没有嵌套 Base64 的额外开销。
文件大小检查区间为 **73–67,108,937 字节**。73 字节只是结构检查的下限;空 payload 不是合法 JSON,因此不能据此构造有效空库。
| 情况 | 当前错误 |
| --- | --- |
| 大小越界、magic 无效、UUID 无效、外层 JSON 或 Base64 无效 | `ServerLibraryError.malformedFile` |
| envelopeVersion 不是 `0x01` | `ServerLibraryError.unsupportedVersion` |
| 大小越界、magic 无效或 UUID 无效 | `ServerLibraryError.malformedFile` |
| envelopeVersion 不是 `0x02` | `ServerLibraryError.unsupportedVersion` |
| 找不到密钥,或密钥不是 32 字节 | `ServerLibraryError.keyUnavailable` |
| GCM 认证或 SealedBox 解析失败 | `ServerLibraryError.authenticationFailed` |
| 目录引用与 `secrets` 键集合不一致 | `ServerLibraryError.missingCredential` |
| 内层目录 JSON、schema 或目录结构校验失败 | `CatalogFileStore.PersistenceError.corrupt` |
| 目录 JSON 解码、schema 版本或目录结构校验失败 | `CatalogFileStore.PersistenceError.corrupt` |
| 钥匙串拒绝访问或需要交互授权 | `CredentialStoreError.keychainStatus`,保留系统状态码 |
| 当前存储实例加载后文件消失,或未成功加载、重置服务器库就尝试保存 | `ServerLibraryError.notLoaded` |
文件系统读取错误按原错误抛出。多个条件同时不满足时,以读取顺序最先遇到的错误为准。
文件系统读取错误按原错误抛出。多个条件同时不满足时,以读取顺序最先遇到的错误为准。Codable 解码先于 schema 版本检查,因此不兼容的 payload 可能在检查版本之前就解码失败。
存在 `catalog.enc` 时,即使读取失败,也不自动退回 `catalog.json`、尝试明文解码或创建空库。用户明确确认放弃原配置后,应用才执行覆盖重置。
存在 `catalog.enc` 时,即使读取失败,也不自动退回 `catalog.json`、尝试明文解码或创建空库。应用阻止服务器库写入,保留已有代理运行。只有用户明确确认放弃原配置后,才执行覆盖重置。
## 6. 写入与迁移
## 6. 写入与历史格式
写入顺序为:收集被引用的秘密 → 目录编码 → 外层 JSON 编码 → 内存加密 → 写入密文临时文件 → 原子替换。
写入顺序为:完整目录 JSON 编码 → 内存加密 → 写入密文临时文件 → 原子替换。服务器库不再单独收集秘密,也不执行逐项凭据写入日志。工作流使用完整工作副本,只有持久化成功后才发布新的已提交值。
目录权限为 `0700`,临时文件创建时的权限为 `0600`。临时文件与目标文件位于同一目录,名称采用 `.catalog.enc.tmp-<UUID>`;临时文件也只包含完整的加密格式。当前写入器没有显式 `fsync`,本文不承诺断电持久性。
没有 `catalog.enc` 时,启动流程读取旧 `catalog.json` 并解析其所需旧凭据;全部读取成功后生成主密钥、提交新密文,再重新读取核对。验证成功后尝试删除旧文件与迁入的逐项凭据。两种库文件都不存在的首次启动,创建新的加密空库。
NG2 尚未发版,读取器不迁移其历史数据:
- 已有封装 v1 文件返回 `ServerLibraryError.unsupportedVersion`;目录 schema v1–v6 不再支持。
- 没有 `catalog.enc` 但存在 `catalog.json` 时,启动返回 `ServerLibraryError.unsupportedVersion`。只检查旧文件是否存在,不读取其内容,不解析旧逐项 Keychain 凭据,也不清理这些文件或凭据。
- 新存储实例仅在两种库文件均不存在时创建加密空库;提交密文前先保存并读回校验新主密钥。
- 用户确认重置时,用新主密钥写入新的空 `catalog.enc`,不删除旧 `catalog.json` 或旧逐项 Keychain 凭据。只有新文件保存成功后,才替换当前主密钥与应用已提交状态。
保留用户主动执行的旧版 ShadowsocksX-NG 服务器导入。该功能构造直接携带秘密值的新记录,与 NG2 历史存储格式迁移不同。
## 7. 格式能力的边界
@@ -183,8 +166,8 @@ v6 的顶层字段为:
## 8. 源码入口
- [ServerLibraryStorage.swift](../ShadowsocksX-NG2/Domain/Catalog/ServerLibraryStorage.swift):文件头、加解密、payload、大小限制与引用集合检查。
- [CatalogFileStore.swift](../ShadowsocksX-NG2/Domain/Catalog/CatalogFileStore.swift):目录 JSON 编码、schema 版本和结构校验。
- [ServerLibraryStorage.swift](../ShadowsocksX-NG2/Domain/Catalog/ServerLibraryStorage.swift):文件头、加解密、payload 大小限制与历史文件处理。
- [CatalogFileStore.swift](../ShadowsocksX-NG2/Domain/Catalog/CatalogFileStore.swift):直接目录 JSON 编码、schema v7 和结构校验。
- [LibraryMasterKeyStore.swift](../ShadowsocksX-NG2/Domain/Credentials/LibraryMasterKeyStore.swift):主密钥的 Keychain 属性与访问方式。
- [CatalogModels.swift](../ShadowsocksX-NG2/Domain/Catalog/CatalogModels.swift)、[SubscriptionRecord.swift](../ShadowsocksX-NG2/Domain/Subscription/SubscriptionRecord.swift):内层目录的 Codable 模型。
- [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):临时文件与权限基线。