# 渠道存储加密 (/zh/docs/channels/encryption)

用 rclone crypt 让存储商只看到密文：防什么、怎么开、为什么建后不可变、密钥从哪里来，以及不依赖 ImgYoung 用标准 rclone 解密渠道数据。

## 防什么，不防什么 [#防什么不防什么]

渠道存储加密针对的是**存储商**：免费或第三方渠道可能扫描、审查存储内容，甚至因误判封号。开启后，渠道上的对象内容与对象名都是密文。

它**不防服务端自身**。ImgYoung 持有主密钥，格式校验、元数据剥离、哈希、查重、按需处理都在加密之前对明文进行，这些功能不受影响；拿到服务器与主密钥的人能读出全部数据。

开启后存储商仍能看到：对象数量、对象大小（由密文大小可推算出明文大小，误差在 16 字节以内）、修改时间与访问规律。

## 实现方式 [#实现方式]

加密渠道在后端之上套一层 rclone 的 `crypt` 后端：

- **内容**：按 64 KiB 分块，用 XSalsa20-Poly1305 加密并校验完整性。
- **对象名**：`standard` 模式，每一级路径用 AES-256（EME 模式）加密后转成小写 base32；目录名同样加密。同一个名字总是加密成同一个密文，所以能按名查找。
- **密钥材料**：rclone 用 scrypt 从「密码」与「盐」派生出实际密钥。ImgYoung 为每个渠道派生一对独立的密码与盐，见[密钥从哪里来](#密钥从哪里来)。

上传、访问代理、补齐、探测都经过这一层自动加解密。渠道上的所有对象都会加密，包括图片、深探测的临时对象，以及每轮备份写到该渠道上的元数据库快照（`_imgyoung/backup/`）。

## 开启方式 [#开启方式]

两级设置，渠道级优先：

| 位置 | 取值 | 默认 |
|---|---|---|
| 「设置 → 运行配置 → 访问与链接 → 新渠道默认加密」（`encrypt_new_channels`）| 开 / 关 | 关 |
| 新建渠道表单的「服务端加密」 | 「继承全局默认」「强制开启」「强制关闭」 | 继承全局默认 |

「继承全局默认」在创建那一刻就按当时的全局设置定下来；之后再改全局设置，不影响已有渠道。通过接口创建时，省略 `encrypted` 字段即为继承。

渠道列表里加密渠道带「加密」标记；编辑表单只显示「已加密」或「未加密」。

## 创建后不可变 [#创建后不可变]

加密属性在创建时固化，之后不能修改。接口拒绝变更并返回 409 `加密属性创建后不可变(变更会造成密文/明文混存且不可恢复);如需调整请新建渠道并迁移数据`。

原因：翻转开关后，同一个渠道里会同时存在按旧设置写入的对象与按新设置写入的对象，旧对象无法按新设置读回。

需要改变加密属性时，用补齐完成迁移：

<Steps>
<Step>
按需要的加密设置新建一个渠道。创建后它会自动为全部存量图片补齐副本。
</Step>
<Step>
等新渠道「副本」列的「待补」归零、没有「失败」。
</Step>
<Step>
删除旧渠道。存量图片在新渠道上都已有就绪副本，唯一副本保护不会拦截。
</Step>
</Steps>

## 加密渠道不能 302 直链 [#加密渠道不能-302-直链]

密文无法直接交给浏览器，所以加密与「允许 302 直链」互斥：

- 新建表单里选了「强制开启」时，「允许 302 直链」不可开；已开直链时，「强制开启」不可选。
- 「继承全局默认」而全局默认为开时，同样不能开直链。
- 接口同时收到两者时拒绝，返回 400 `渠道参数非法: 加密渠道不允许直链(密文无法直出)`。
- `access_mode = redirect` 时，加密渠道上的图片始终走代理。

## 密钥从哪里来 [#密钥从哪里来]

每个加密渠道的密码与盐都由**主密钥**派生，不另外保存：

| 项 | 取值 |
|---|---|
| 算法 | HKDF-SHA256，盐为空（等同 32 个零字节），输出 32 字节 |
| 输入密钥 | 主密钥的 32 字节 |
| 密码的 info | `channel:<渠道 ID>:<创建时刻>:crypt:password` |
| 盐的 info | `channel:<渠道 ID>:<创建时刻>:crypt:salt` |
| 交给 rclone 的值 | 32 字节结果的小写十六进制串（64 个字符），分别作为 crypt 的 `password` 与 `password2` |

- **渠道 ID** 与 **创建时刻** 取自元数据库 `channels` 表的 `id` 与 `created_at` 列。创建时刻是 SQLite 写入的 UTC 文本，形如 `2026-10-15 08:00:00`，派生时逐字使用。
- 带上创建时刻，是因为 SQLite 会复用已删除渠道的 ID；只绑定 ID 时，新渠道会派生出与已删渠道相同的密钥。
- 不同渠道的密钥互相独立，一个渠道的密钥泄露不波及其他渠道。

**主密钥**来自环境变量 `IMGYOUNG_MASTER_KEY`（64 位十六进制），未设置或为空时使用 `<数据目录>/master.key`。它同时用于加密渠道配置与派生渠道密钥。主密钥不包含在数据导出与元数据库快照里。

首次以某把主密钥启动时，元数据库会记下由它派生的指纹（不能反推出主密钥），此后每次启动都核对：

- 只有全新的元数据库在找不到主密钥时，才生成 `master.key`（权限 0600）。
- 库里已有指纹时，主密钥缺失（环境变量未设置且没有 `master.key`）或与指纹不符，一律拒绝启动并说明原因，不会生成新密钥。
- 恢复别的实例的快照时，要同时换上那个实例的主密钥。

<Callout type="error" title="主密钥丢失 = 加密渠道数据永久丢失">
没有主密钥，就派生不出任何加密渠道的密码，渠道上的密文无法解开；已保存的渠道配置也无法解密，ImgYoung 会拒绝启动。把主密钥复制到与服务器分离的安全位置。主密钥的配置见[配置参考](../configuration.mdx)，备份策略见[元数据库备份](../operations/backup.mdx)。
</Callout>

## 自救：用标准 rclone 读出渠道数据 [#自救用标准-rclone-读出渠道数据]

crypt 是 rclone 的公开格式。即使 ImgYoung 不再运行，也可以用标准 rclone 读出加密渠道上的全部对象。最省事的做法是事先导出每个渠道的 rclone 配置文件；没有导出文件时，也可以从元数据库与主密钥手工推导。

<Callout type="warn" title="快照也在加密渠道上时">
元数据库快照每轮写到每个健康渠道，加密渠道上的那一份同样是密文。只要有一个未加密的渠道持有快照，或者已经离线保存了加密渠道的导出文件，主机全损后就能取回快照。如果全部渠道都加密、又没有导出文件，手工推导需要渠道的 ID 与创建时刻，而这两项正记录在快照里。这种部署请为每个加密渠道导出配置文件，与主密钥一起离线保存。
</Callout>

### 用导出的配置文件 [#用导出的配置文件]

<Steps>
<Step>
在「存储渠道」页的行菜单点「导出 rclone 配置」，再点「下载配置文件」，得到 `imgyoung-channel-<渠道 ID>.rclone.conf`。每个渠道一个文件，渠道配置改动后重新导出。
</Step>
<Step>
把它和主密钥一起离线保存。文件本身就足以读写这个渠道上的全部数据（加密渠道的解密密码已在其中）；主密钥用于恢复 ImgYoung 自身。
</Step>
<Step>
在任意装有 rclone 的机器上，用文件头注释里的命令读出数据。加密渠道读 `imgyoung-<渠道 ID>-crypt:`，得到的就是明文：

```bash title="终端"
rclone --config imgyoung-channel-3.rclone.conf ls imgyoung-3-crypt:
rclone --config imgyoung-channel-3.rclone.conf copy imgyoung-3-crypt: ./imgyoung-restore
```

未加密的渠道直接读后端远端加存储路径，如 `imgyoung-3:my-bucket/imgyoung`，文件头注释里写着这个路径。
</Step>
</Steps>

一个加密 R2 渠道导出的文件形如：

```ini title="imgyoung-channel-3.rclone.conf"
# ImgYoung 渠道 "r2-backup"(ID 3,创建于 2026-10-15 08:00:00 UTC)的 rclone 配置。
# 文件含渠道凭证与解密密码,与主密钥一样离线妥善保存。
#
# [imgyoung-3] 渠道后端(s3),参数与 ImgYoung 所用一致
# [imgyoung-3-crypt] 渠道的加密层,指向渠道存储路径 imgyoung-3:my-bucket/imgyoung,经它读写的是明文
#
# 用法:
#   rclone --config imgyoung-channel-3.rclone.conf ls imgyoung-3-crypt:
#   rclone --config imgyoung-channel-3.rclone.conf copy imgyoung-3-crypt: ./imgyoung-restore

[imgyoung-3]
type = s3
access_key_id = <Access Key>
endpoint = https://<账户 ID>.r2.cloudflarestorage.com
no_check_bucket = true
provider = Cloudflare
region = auto
secret_access_key = <Secret Key>

[imgyoung-3-crypt]
type = crypt
filename_encryption = standard
password = <rclone 编码后的密码>
password2 = <rclone 编码后的盐>
remote = imgyoung-3:my-bucket/imgyoung
```

- 后端远端的参数与 ImgYoung 交给后端的完全一致，包括凭证与授权令牌；密码类参数是 rclone 编码后的形式，rclone 直接可用。s3 渠道带 `no_check_bucket = true`。
- 文件头注释固定为中文。
- 持有授权令牌的网盘渠道，注释里多两行提醒：rclone 用这个文件时会刷新令牌并写回文件；Box 等刷新令牌只能用一次的服务商，文件与仍在使用该渠道的 ImgYoung 会互相作废对方的令牌，不要同时使用。另外，ImgYoung 刷新令牌之后，文件里的令牌就过时了；对 Box 这类服务商，文件里的刷新令牌随即作废，要重新导出。
- `local` 渠道的路径是服务器上的路径（内置 local 渠道是 `<数据目录>/blobs`，数据目录配成相对路径时导出的也是相对路径），换一台机器使用要改成实际位置。
- 参数值写不进 rclone 配置文件（如含换行）时，导出返回 422，错误只指出远端名与参数键。每次下载都记一条审计日志「导出渠道 rclone 配置」。

### 没有导出文件时：手工推导 [#没有导出文件时手工推导]

需要准备：

- 主密钥（64 位十六进制）；
- 渠道的 ID 与创建时刻；
- 渠道后端的凭证与渠道的存储路径（桶名及前缀、网盘目录等）。

<Steps>
<Step>
#### 取得渠道 ID 与创建时刻 [#取得渠道-id-与创建时刻]

元数据导出的 JSON 与渠道列表接口里，每个渠道都有 `id` 与 `created_at`。只剩元数据库文件时，用 `sqlite3` 查询；元数据库是 `<数据目录>/imgyoung.db`，快照文件也是同样格式的 SQLite 库：

```bash title="终端"
sqlite3 imgyoung.db "SELECT id, name, created_at FROM channels WHERE encrypted = 1;"
```

```text title="输出示例"
3|r2-backup|2026-10-15 08:00:00
```
</Step>
<Step>
#### 计算密码与盐 [#计算密码与盐]

下面的脚本只用 Python 标准库，按 HKDF-SHA256 计算出密码与盐：

```python title="derive.py"
import hashlib, hmac, sys

master = bytes.fromhex(sys.argv[1].strip())
channel_id, created_at = sys.argv[2], sys.argv[3]
prk = hmac.new(b"\x00" * 32, master, hashlib.sha256).digest()
for usage in ("password", "salt"):
    info = f"channel:{channel_id}:{created_at}:crypt:{usage}".encode()
    print(usage, hmac.new(prk, info + b"\x01", hashlib.sha256).hexdigest())
```

```bash title="终端"
python3 derive.py "$(cat master.key)" 3 "2026-10-15 08:00:00"
```

输出两行：`password <64 位十六进制>` 与 `salt <64 位十六进制>`。
</Step>
<Step>
#### 配置 rclone [#配置-rclone]

先建一个指向渠道后端的普通 remote，参数与渠道的后端参数一致（以 Cloudflare R2 为例）：

```bash title="终端"
rclone config create imgyoung-r2 s3 provider=Cloudflare \
  access_key_id=<Access Key> secret_access_key=<Secret Key> \
  endpoint=https://<账户 ID>.r2.cloudflarestorage.com region=auto
```

再在它之上建 crypt remote。`remote` 指向渠道的存储路径（模板里「存储桶」一栏的值，含前缀）；`--obscure` 不能省，它让 rclone 把两个十六进制串当作明文密码编码：

```bash title="终端"
rclone config create imgyoung-crypt crypt \
  remote=imgyoung-r2:<存储桶>/<前缀> \
  password=<上一步的 password> password2=<上一步的 salt> \
  filename_encryption=standard --obscure
```
</Step>
<Step>
#### 读出数据 [#读出数据]

```bash title="终端"
rclone ls imgyoung-crypt:
rclone copy imgyoung-crypt: ./imgyoung-restore
```
</Step>
</Steps>

### 读出的内容 [#读出的内容]

图片对象名形如 `<短 ID>_<SHA-256 前 8 位>`，不带扩展名；短 ID 就是图片链接里的 ID。元数据库快照在 `_imgyoung/backup/` 下。
