mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-06 06:24:17 +00:00
174 lines
12 KiB
Markdown
174 lines
12 KiB
Markdown
# Server Library Encryption File Format v2
|
||
|
||
English | [简体中文](server-library-encryption-format.zh-CN.md)
|
||
|
||
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.
|
||
|
||
## 1. File and Protection Scope
|
||
|
||
The default path is `~/Library/Application Support/ShadowsocksX-NG2/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 |
|
||
| --- | --- | --- |
|
||
| `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 |
|
||
|
||
An empty library has this payload:
|
||
|
||
```json
|
||
{
|
||
"entries": [],
|
||
"favoriteIDs": [],
|
||
"rootChildren": [],
|
||
"subscriptions": [],
|
||
"version": 7
|
||
}
|
||
```
|
||
|
||
`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.
|
||
|
||
### 4.2 Secrets Belong to Records
|
||
|
||
| 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 |
|
||
|
||
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 |
|
||
| --- | --- |
|
||
| 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` |
|
||
|
||
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 blocks library writes and preserves the existing proxy runtime. Overwriting the library requires explicit user confirmation to discard the existing configuration.
|
||
|
||
## 6. Writing and Historical Formats
|
||
|
||
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.
|
||
|
||
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
|
||
|
||
- 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.
|
||
|
||
## 8. Source Entry Points
|
||
|
||
- [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 catalog records and inline secrets.
|
||
- [AtomicFileWriter.swift](../ShadowsocksX-NG2/Domain/Runtime/AtomicFileWriter.swift): temporary files and permission baseline.
|