Encryption
For the envelope-encryption design, decorator pattern, wire frame layout, KMIP/HSM integration details, and key hierarchy, see ../internals/encryption-design.md.
DittoFS can encrypt every block before it leaves the server using a per-store, decorator-based encryption layer. Encryption is opt-in per block store.
What encryption protects (and what it does not)
Section titled “What encryption protects (and what it does not)”Encryption protects block payloads against:
- Operators of the block store (S3 provider, MinIO admins).
- Anyone with read access to the bucket / prefix where blocks are stored.
- Theft of the underlying storage media.
Encryption does not protect:
- Metadata. Filenames, directory structure, file sizes, and timestamps are stored in the metadata backend in plaintext.
- In-memory state. Plaintext blocks live in the cache (RAM and disk tier) while a share is mounted. For full at-rest protection of the cache, place each share’s local storage directory on an encrypted filesystem (FileVault / LUKS / dm-crypt).
- Compromised dfs daemons. The master key bytes live in process memory for the daemon’s lifetime; anyone with
ptraceagainst the daemon can recover them.
Enabling encryption
Section titled “Enabling encryption”Encryption is enabled per block store by setting an encryption block in the store’s config. Add it via dfsctl at store-creation time:
# Generate a passphrase-protected key file.# DittoFS derives the file-encryption key with Argon2id,# so high passphrase entropy gives high real entropy.read -srp 'passphrase: ' DITTOFS_ENCRYPTION_PASSPHRASE; export DITTOFS_ENCRYPTION_PASSPHRASE
# Local-file providerdfsctl store block add \ --name s3-encrypted --type s3 --bucket prod-data \ --encryption-aead aes-256-gcm \ --encryption-key-kind local \ --encryption-key-file /etc/dittofs/keys/share.key
# KMIP provider (HSM-backed master key)dfsctl store block add \ --name s3-hsm --type s3 --bucket regulated-data \ --encryption-aead aes-256-gcm \ --encryption-key-kind kmip \ --encryption-kmip-endpoint kms.example.com:5696 \ --encryption-kmip-cert /etc/dittofs/kmip/client.pem \ --encryption-kmip-key /etc/dittofs/kmip/client.key \ --encryption-kmip-ca /etc/dittofs/kmip/ca.pem \ --encryption-kmip-key-uid 12345-abcde-...Generate a fresh key file (no dedicated subcommand — call the Go helper directly):
import "github.com/marmos91/dittofs/pkg/block/middleware/encryption/keyprovider"
bytes, _ := keyprovider.GenerateKeyFile("your-strong-passphrase")os.WriteFile("/etc/dittofs/keys/share.key", bytes, 0o600)Configuration reference
Section titled “Configuration reference”encryption: aead: aes-256-gcm # aes-256-gcm | chacha20-poly1305 | xchacha20-poly1305 key: kind: local # local | kmip # kind=local file: /etc/dittofs/keys/share.key retired_files: # optional, decrypt-only (see rotation below) - /etc/dittofs/keys/share-2026-02.key # kind=kmip endpoint: kms.example.com:5696 server_ca: /etc/dittofs/kmip/ca.pem client_cert: /etc/dittofs/kmip/client.pem client_key: /etc/dittofs/kmip/client.key key_uid: 12345-abcde-... retired_key_uids: # optional, decrypt-only - 09876-zyxwv-... timeout_ms: 5000file / key_uid name the current master key: everything written from
now on is wrapped under it. retired_files / retired_key_uids name keys
that are used for decryption only. Both retired lists default to empty, so
a config written before rotation existed keeps behaving exactly as it did.
All retired keys share the current key’s passphrase
(DITTOFS_ENCRYPTION_PASSPHRASE); there is no per-file passphrase.
AEAD cipher choices
Section titled “AEAD cipher choices”| Cipher | Notes |
|---|---|
aes-256-gcm | Hardware-accelerated on most CPUs; recommended default |
chacha20-poly1305 | Software-friendly; good where AES-NI is absent |
xchacha20-poly1305 | Extended nonce (24 bytes); lower collision probability for large volumes |
Passphrase handling
Section titled “Passphrase handling”The passphrase that unlocks a local key file is read only from the DITTOFS_ENCRYPTION_PASSPHRASE environment variable. The daemon (and dfsctl when it loads a provider) will fail to start if the variable is unset.
Argon2id parameters (m = 64 MiB, t = 3, p = 4) match the OWASP 2024 password-storage guidance.
Operational warnings
Section titled “Operational warnings”Read this section before turning encryption on in production.
Enable encryption at remote-store creation time only, and never remove it
Section titled “Enable encryption at remote-store creation time only, and never remove it”Adding an encryption block to a block store that already contains plaintext blocks will make every existing block permanently unreadable through the share — Get will return ErrCiphertextWithoutFrame because the stored bytes lack the DFENC frame header. The decorator refuses to interpret unframed bytes on an encryption-enabled share; that is intentional (any other behaviour would let a tampered-S3 actor force a plaintext downgrade).
Recommendation: create new block stores with encryption enabled, migrate data across, then decommission the unencrypted store.
The reverse direction is refused outright: an update that removes the encryption block from a store that has one returns 400 Bad Request. Blocks already written carry a DFENC frame, and an undecorated store would hand that framed ciphertext back to clients as if it were plaintext without erroring anywhere. Changing the encryption policy in place — rotating the key, retiring an old one — stays allowed. To genuinely stop encrypting, create a new store and migrate onto it.
Retiring a master key is one-way — you cannot un-retire what you deleted
Section titled “Retiring a master key is one-way — you cannot un-retire what you deleted”Rotation is supported, and does not re-encrypt any data. Every stored frame
records the identifier of the master key that wrapped its block key, so
Unwrap routes each block to the key it was written under.
To rotate:
- Generate the new key file the same way you generated the first one (the
GenerateKeyFilehelper above — there is no dedicated subcommand), or register the new key UID with the HSM. - Move the current
file/key_uidvalue intoretired_files/retired_key_uids. - Point
file/key_uidat the new key. - Restart the share.
From that point, new blocks are wrapped under the new key and old blocks keep decrypting under the retired one. Nothing is ever wrapped under a retired key again.
The hazard is step 2 in reverse. Dropping a key from retired_files, or
deleting the key file it names, makes every block still wrapped under that
key permanently unreadable — there is no bulk re-wrap command yet, so
there is no supported way to move existing blocks onto the current key and
no way to enumerate which blocks still reference an old one. Until that
ships, treat retired keys as keep-forever: leave them configured and keep
the key material backed up. Retiring a key costs one HSM fetch or one file
read at startup, so a handful of them is not a burden worth trimming.
Two operational notes:
- A retired key that cannot be read at startup is logged and skipped rather than being fatal — blocks under it become unreadable, but the share (and every other share on the daemon) still starts. A missing current key is still fatal.
- Two keys claiming the same identifier is rejected at startup, because
which one
Unwrappicks would otherwise be arbitrary. This is what listing the same file twice, or listing the current key as retired, will produce.
A KMIP key that is not Active stops the share from starting
Section titled “A KMIP key that is not Active stops the share from starting”The KMIP provider reads the object’s State attribute (via read-only GetAttributes) before fetching any key material, and refuses to bring the encrypted store up unless the current key_uid is Active. Deactivated, Compromised, Pre-Active and Destroyed are all rejected, with the state and the uid named in the error. Revoking a key at the HSM is the standardised way of saying “stop using this”, so it stops us using it; there is no read-only or degraded mode, and no way to override the check from config.
The practical trap is Deactivated, which is the state a key normally enters when it is rotated out at the HSM end. If someone rotates in the HSM without moving key_uid on the DittoFS side, the share will not come back after its next restart. Rotate in both places, in that order.
Retired uids are judged by a looser rule, because blocks already written under them have to stay readable:
State of a retired_key_uids entry | Behaviour |
|---|---|
Active, Deactivated, Pre-Active | Loaded normally — Deactivated is the expected steady state for a retired key |
Compromised | Loaded, with a WARN recording that data is still being read under a compromised key. Treat that line as the trigger for a re-wrap decision |
Destroyed | Skipped, naming the state. The HSM has no material to return; blocks under that key are unreadable |
A server that answers GetAttributes without a State attribute is treated as an error rather than as Active — an unreadable state is not evidence that the key is usable.
Only a share whose store fails this way is affected: the daemon logs the refusal and starts without it, rather than failing the whole start. Note that a share skipped this way is currently visible only in the log — dfsctl share list will not flag it.
AAD is per-block, not per-share
Section titled “AAD is per-block, not per-share”The associated data bound into the AEAD is the 32-byte BLAKE3 plaintext hash. It binds ciphertext to its CAS address but does not bind it to a share identity. Two shares that reference the same block store config — and therefore share the same master key — could decrypt each other’s blocks if an attacker with direct object-store write access moved blocks between share namespaces. This is acceptable for the supported configuration (one block-store config per workload) but is a hazard if you reuse one master key across security-domain-distinct shares. Do not do that.
What’s not in scope (yet)
Section titled “What’s not in scope (yet)”- Bulk re-wrap — rotation works (see above), but there is no job that reads blocks under a retired key and rewrites them under the current one, and no way to enumerate which blocks still reference a given key. Both are required before a retired key can ever be safely dropped.
- Filename / size / timestamp encryption — out of scope; metadata stays unencrypted.
- Encrypted disk cache tier — current cache holds plaintext in RAM / disk; use an encrypted filesystem underneath if needed.
- Key provisioning over KMIP — deliberate, not unfinished. The client only ever reads:
GetandGetAttributes. It cannotCreate,Register,Activate,RevokeorDestroy, so the credential DittoFS holds cannot be used to make or destroy key material, and provisioning stays an out-of-band operator responsibility. This mirrors how KMS-backed storage works elsewhere (S3 SSE-KMS callsGenerateDataKey/Decrypt; it does not create CMKs). Give the DittoFS client a read-only credential. Locateby key name — resolving a human-readable key name to a uid is read-only and would be an ergonomics win, but is not implemented; configure the uid directly.- FIPS 140-3 mode — would require swapping Argon2id for PBKDF2-SHA256, pinning AES-only AEADs, and building with the BoringCrypto tag.