# Channel encryption (/en/docs/channels/encryption)

Store only ciphertext at your provider with rclone crypt — what it protects against, how to turn it on, why it is fixed at creation, where the keys come from, and how to decrypt a channel with stock rclone without ImgYoung.

## What it protects against [#what-it-protects-against]

Channel encryption is aimed at the **storage provider**. Free or third-party storage may scan or review what you store, sometimes closing accounts over false positives. With encryption on, both object contents and object names on the channel are ciphertext.

It does **not** protect against the server itself. ImgYoung holds the master key, and format checks, metadata stripping, hashing, deduplication and on-demand processing all run on plaintext before encryption, so none of them are affected; anyone with the server and the master key can read everything.

The provider can still see the number of objects, their sizes (the plaintext size can be worked out from the ciphertext size to within 16 bytes), modification times and access patterns.

## How it works [#how-it-works]

An encrypted channel wraps its backend in rclone's `crypt` backend:

- **Contents** are split into 64 KiB blocks and encrypted with XSalsa20-Poly1305, which also authenticates them.
- **Object names** use the `standard` mode: each path segment is encrypted with AES-256 in EME mode and written as lower-case base32; folder names are encrypted too. A given name always encrypts to the same ciphertext, which is what makes lookups by name possible.
- **Key material**: rclone derives the actual keys with scrypt from a password and a salt. ImgYoung derives a separate password and salt for every channel, see [Where the keys come from](#where-the-keys-come-from).

Uploads, proxied reads, replication and health checks all pass through this layer transparently. Everything on the channel is encrypted: images, deep-check temporary objects, and the metadata snapshots (`_imgyoung/backup/`) that each backup round writes to the channel.

## Turning it on [#turning-it-on]

There are two levels, and the channel level wins:

| Where | Values | Default |
|---|---|---|
| **Settings → Runtime settings → Access & links → Encrypt new channels by default** (`encrypt_new_channels`) | On / off | Off |
| **Server-side encryption** in the new-channel form | **Inherit the global default**, **Force on**, **Force off** | Inherit the global default |

"Inherit the global default" is resolved at the moment of creation; changing the global setting later does not affect existing channels. Through the API, omitting the `encrypted` field means inherit.

Encrypted channels carry an **Encrypted** badge in the list; the edit form only shows **Encrypted** or **Not encrypted**.

## Fixed at creation [#fixed-at-creation]

The encryption attribute is set at creation and cannot change afterwards. The API rejects changes with 409 and `加密属性创建后不可变(变更会造成密文/明文混存且不可恢复);如需调整请新建渠道并迁移数据` ("encryption cannot change after creation; create a new channel and migrate").

The reason: after flipping the switch, one channel would hold objects written under the old setting next to objects written under the new one, and the old objects could not be read back under the new setting.

To change encryption, let replication do the migration:

<Steps>
<Step>
Create a new channel with the encryption setting you want. It backfills a replica of every existing image on its own.
</Step>
<Step>
Wait until the new channel's **Replicas** column shows zero pending and no failures.
</Step>
<Step>
Delete the old channel. Every image now has a ready replica on the new channel, so the only-replica protection does not block it.
</Step>
</Steps>

## No 302 direct links on encrypted channels [#no-302-direct-links-on-encrypted-channels]

Ciphertext cannot be handed to a browser, so encryption and **Allow 302 direct links** exclude each other:

- In the new-channel form, choosing **Force on** disables **Allow 302 direct links**; with direct links on, **Force on** cannot be selected.
- The same applies when you choose **Inherit the global default** while the global default is on.
- The API rejects a request asking for both with 400 and `渠道参数非法: 加密渠道不允许直链(密文无法直出)` ("invalid channel parameters: encrypted channels cannot serve direct links").
- With `access_mode = redirect`, images on encrypted channels are always proxied.

## Where the keys come from [#where-the-keys-come-from]

Each encrypted channel's password and salt are derived from the **master key** and never stored on their own:

| Item | Value |
|---|---|
| Algorithm | HKDF-SHA256, empty salt (equivalent to 32 zero bytes), 32 bytes of output |
| Input key | The 32 bytes of the master key |
| Password info | `channel:<channel ID>:<created at>:crypt:password` |
| Salt info | `channel:<channel ID>:<created at>:crypt:salt` |
| Given to rclone | The lower-case hex string of the 32-byte result (64 characters), as crypt's `password` and `password2` respectively |

- The **channel ID** and **created at** come from the `id` and `created_at` columns of the `channels` table in the metadata database. `created_at` is UTC text written by SQLite, such as `2026-10-15 08:00:00`, and is used verbatim.
- The creation time is part of the input because SQLite reuses IDs of deleted channels; binding to the ID alone would let a new channel derive the same keys as a deleted one.
- Keys of different channels are independent; leaking one channel's keys does not expose the others.

The **master key** comes from the environment variable `IMGYOUNG_MASTER_KEY` (64 hex characters) or, when that is unset or empty, from `<data dir>/master.key`. It encrypts the channel configurations as well as deriving the channel keys. It is not part of the data export or of metadata snapshots.

The first start with a given master key records a fingerprint derived from it in the metadata database (the key cannot be recovered from it), and every later start checks it:

- `master.key` is generated (with permissions 0600) only when a brand-new metadata database finds no master key.
- Once a fingerprint is recorded, a missing master key (no environment variable and no `master.key`) or one that does not match makes startup fail with an explanation; no new key is generated.
- When restoring a snapshot from another instance, switch to that instance's master key as well.

<Callout type="error" title="Losing the master key loses encrypted channels for good">
Without the master key no encrypted channel's password can be derived, so the ciphertext stays unreadable; the stored channel configurations cannot be decrypted either, and ImgYoung refuses to start. Keep a copy of the master key somewhere separate from the server. Configuration is covered in the [configuration reference](../configuration.mdx), backup strategy in [Metadata backups](../operations/backup.mdx).
</Callout>

## Self-rescue: reading a channel with stock rclone [#self-rescue-reading-a-channel-with-stock-rclone]

crypt is a published rclone format. Even with ImgYoung gone, stock rclone can read every object on an encrypted channel. The easy way is to export each channel's rclone config file in advance; without an exported file you can still derive everything by hand from the metadata database and the master key.

<Callout type="warn" title="When the snapshots are on an encrypted channel too">
Each backup round writes the metadata snapshot to every healthy channel, and the copy on an encrypted channel is ciphertext as well. As long as one unencrypted channel holds a snapshot, or you have kept the encrypted channels' exported files offline, you can get a snapshot back after losing the host. If every channel is encrypted and there is no exported file, deriving by hand needs the channel's ID and creation time — and those are recorded inside the snapshot. In such a deployment, export a config file for every encrypted channel and keep it offline with the master key.
</Callout>

### With an exported config file [#with-an-exported-config-file]

<Steps>
<Step>
On the Storage channels page choose **Export rclone config** in the row menu, then **Download config file**, to get `imgyoung-channel-<channel ID>.rclone.conf`. There is one file per channel; export again after changing a channel's configuration.
</Step>
<Step>
Keep it offline together with the master key. The file alone is enough to read and write all of this channel's data (an encrypted channel's decryption passwords are in it); the master key is what you need to restore ImgYoung itself.
</Step>
<Step>
On any machine with rclone, run the commands from the comments at the top of the file. For an encrypted channel read `imgyoung-<channel ID>-crypt:`, which yields plaintext:

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

For an unencrypted channel read the backend remote plus the storage path, such as `imgyoung-3:my-bucket/imgyoung`; the comments at the top of the file give that path.
</Step>
</Steps>

The file exported for an encrypted R2 channel looks like this (the comments at the top are always in Chinese):

```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://<account 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-encoded password>
password2 = <rclone-encoded salt>
remote = imgyoung-3:my-bucket/imgyoung
```

- The backend remote has exactly the parameters ImgYoung hands to the backend, credentials and OAuth tokens included; password-type parameters are in rclone's encoded form, which rclone uses as is. s3 channels carry `no_check_bucket = true`.
- For drive channels holding an OAuth token, the comments add two lines: rclone refreshes the token when using this file and writes it back into the file; with providers whose refresh tokens are single-use, such as Box, the file and an ImgYoung still using the channel invalidate each other's tokens, so do not use both at once. Also, once ImgYoung refreshes the token, the copy in the file is stale; with Box and the like its refresh token is invalidated and you need to export again.
- A `local` channel's path is a path on the server (for the built-in local channel `<data dir>/blobs`, which is relative if the data directory is configured as a relative path); change it to the real location when using the file on another machine.
- If a parameter value cannot be written into an rclone config file (a line break, for example), the export returns 422 and the error names only the remote and the parameter key. Every download is recorded in the audit log as "Export channel rclone config".

### Without an exported file: deriving by hand [#without-an-exported-file-deriving-by-hand]

You need:

- the master key (64 hex characters);
- the channel's ID and creation time;
- the backend credentials and the channel's storage path (bucket and prefix, drive folder, and so on).

<Steps>
<Step>
#### Get the channel ID and creation time [#get-the-channel-id-and-creation-time]

Every channel in the metadata export JSON and in the channel list API has `id` and `created_at`. If all you have is the metadata database file, query it with `sqlite3`; the database is `<data dir>/imgyoung.db`, and snapshot files are SQLite databases of the same shape:

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

```text title="Example output"
3|r2-backup|2026-10-15 08:00:00
```
</Step>
<Step>
#### Compute the password and salt [#compute-the-password-and-salt]

This script uses only the Python standard library to compute the password and salt with 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="Terminal"
python3 derive.py "$(cat master.key)" 3 "2026-10-15 08:00:00"
```

It prints two lines: `password <64 hex characters>` and `salt <64 hex characters>`.
</Step>
<Step>
#### Configure rclone [#configure-rclone]

First create a plain remote for the channel's backend, using the same parameters as the channel (Cloudflare R2 as an example):

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

Then layer a crypt remote on top. `remote` points at the channel's storage path (the value of the bucket field in the template, prefix included). Do not drop `--obscure`: it tells rclone to treat both hex strings as plain passwords and encode them:

```bash title="Terminal"
rclone config create imgyoung-crypt crypt \
  remote=imgyoung-r2:<bucket>/<prefix> \
  password=<password from the previous step> password2=<salt from the previous step> \
  filename_encryption=standard --obscure
```
</Step>
<Step>
#### Read the data [#read-the-data]

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

### What you get [#what-you-get]

Image objects are named `<short ID>_<first 8 hex digits of SHA-256>`, without an extension; the short ID is the ID in the image link. Metadata snapshots are under `_imgyoung/backup/`.
