Skip to content

Add optional chunk encryption to stores#371

Open
folbricht wants to merge 10 commits into
masterfrom
chunk-encryption
Open

Add optional chunk encryption to stores#371
folbricht wants to merge 10 commits into
masterfrom
chunk-encryption

Conversation

@folbricht

@folbricht folbricht commented Jul 8, 2026

Copy link
Copy Markdown
Owner

Revives #182, rebased onto the current codebase and reworked based on the discussion in that PR.

Chunks can be encrypted at rest on a per-store basis using XChaCha20-Poly1305 (default) or AES-256-GCM. Encryption is implemented as a converter layer on top of the framework from #181: it is applied after compression when writing to a store and reversed when reading. Encrypted chunks are stored with a file extension containing the algorithm and a key ID (the leading 4 bytes of SHA256 of the key), e.g. <hash>.cacnk.xchacha20-poly1305-e1e2b9f0, so chunks with different settings or keys can coexist in the same store. prune and verify only consider chunks matching the store's configured extension.

The main change compared to #182: instead of deriving the key from a password with SHA256, the user provides the raw 256-bit key hex-encoded (e.g. generated with openssl rand -hex 32). As discussed in the original PR, password-derived keys are only as strong as the password and can be brute-forced cheaply, especially since the key ID in the chunk file name acts as a fast verification oracle. Requiring the full-entropy key eliminates that class of problem, and makes the key ID in the extension harmless.

Configuration:

  • encryption, encryption-key, and encryption-algorithm in store-options
  • DESYNC_ENCRYPTION_KEY environment variable as fallback for the key, to keep it out of config files
  • chunk-server --encryption / --encryption-key to serve/accept encrypted chunks, independent of how the upstream store is configured. The env variable supplies the key but never enables encryption on its own, and enabling encryption without a key is an error

Also included from the rebase: the converter interface now determines chunk file extensions (storageExtension()), replacing the CompressedChunkExt/UncompressedChunkExt constants, and the now-unused hasCompression() helper was removed.

Note: encryption protects chunk contents only. Chunk file names remain content hashes of the plain data, so an observer who already knows a plaintext can confirm its presence in a store. Integrity comes from the regular chunk content validation rather than from the AEAD, which authenticates ciphertext under the key but does not bind it to the chunk name. Both properties are documented in the README, including the guidance to only use skip-verify where a downstream reader still validates.

folbricht added 4 commits July 9, 2026 09:56
Chunks can now be encrypted at rest on a per-store basis using
XChaCha20-Poly1305 (default) or AES-256-GCM. Encryption is implemented
as a converter layer, applied after compression when writing to a store
and reversed when reading. Encrypted chunks carry a file extension with
the algorithm name and a key ID (leading 4 bytes of SHA256(key)) so that
chunks with different settings or keys can share a store.

The 256-bit encryption key is provided hex-encoded, either in the
store-options of the config file (encryption-key), via the
DESYNC_ENCRYPTION_KEY environment variable, or with the chunk-server
--encryption-key option. Requiring the raw key rather than deriving one
from a password avoids weak, brute-forceable passwords entirely; a
suitable key can be generated with `openssl rand -hex 32`.

This revives and reworks the changes from #182 on top of the current
codebase.
- Move the Compressor converter from compress.go into converter.go so
  the datadog build variant compiles again; compress.go is only built
  without the datadog tag.
- Reject store options that set encryption-key or encryption-algorithm
  without enabling encryption instead of silently writing plaintext.
- Require an explicit flag to enable encryption in chunk-server. The
  DESYNC_ENCRYPTION_KEY environment variable alone no longer switches
  the wire format, it is only consulted once encryption is enabled with
  --encryption, --encryption-key or --encryption-algorithm. Enabling
  encryption without a key is now an error rather than silently serving
  plaintext.
- Fail when encryption is configured on casync protocol (ssh://) stores
  which don't support it, rather than silently ignoring it.
- Reject chunk requests whose name carries an unexpected extension with
  a clear error again. The suffix check alone was a no-op for servers
  without compression and encryption, and such requests only failed
  deep in the chunk ID parser.
Encryption provides confidentiality while integrity relies on the
regular chunk content validation, since the AEAD ciphertext is
authenticated under the key but not bound to the chunk name. Spell
that out in the encryption docs, including the consequence that
skip-verify should only be used where a downstream reader still
validates chunks.
- StorageConverters returns the named Converters type instead of
  []converter, giving callers access to its methods without a cast.
- Derive the chunk file extension from the converters once at store
  construction rather than rebuilding the string on every chunk
  operation. All stores and the HTTP handler now follow the pattern
  the SFTP store already used, and SFTPStore.Prune uses the pooled
  base's extension instead of deriving it a second way.
- Collapse the two identical AEAD converter implementations into one
  aeadConverter parameterized by algorithm, keyed comparison now
  includes the algorithm name. Adding an algorithm only requires a
  constructor and a case in StorageConverters.
Indexes are always stored in plain form. A config entry enabling
encryption that matches a location used for indexes was silently
ignored, which could be mistaken for indexes being stored encrypted.
Index stores now fail with an error instead, checked both in the
store constructors and centrally in the command's index store
factory to cover backends that don't take store options. Documented
in the README along with the metadata a plain index reveals about
encrypted content.

Also consolidates the DESYNC_ENCRYPTION_KEY fallback into one helper
used by both the config and the chunk-server paths, dedupes the
algorithm selection in StorageConverters, has newSFTPStoreBase derive
its converters itself instead of taking a pre-computed extension
parameter, and moves repeated key-decoding in tests to the testKey
helper.
When a chunk's storage format and the requested format share leading
converter layers, Chunk.Storage now applies just the difference
instead of converting back to plain data and rebuilding the whole
stack. A chunk-server serving encrypted chunks from a compressed
upstream store no longer decompresses and recompresses every chunk,
it only adds the encryption layer, and the reverse direction only
removes it. This extends the existing avoid-recompression behavior
for exactly matching converter stacks, which remains a special case
of the same comparison.
Chunk.Storage now finds the longest common prefix of the two converter
stacks and converts only the difference in both directions. This
extends the previous prefix-only optimization to stacks that share
layers but diverge, most notably re-encrypting chunks with a new key,
which no longer decompresses and recompresses every chunk. Requesting
plain data goes through Data() again so the result is cached on the
chunk, which the previous layer-diffing skipped, causing chunks copied
into an uncompressed cache to be decompressed twice. Converters.equal
and trimPrefix are replaced by the single commonPrefix helper.

Conversion errors during chunk validation are no longer swallowed.
ChunkInvalid gained an optional cause and validation reports it, so a
tampered or truncated encrypted chunk now surfaces the underlying
error, e.g. "message authentication failed", instead of a hash
mismatch against an all-zero sum. Repair semantics are unchanged,
such failures are still ChunkInvalid.
- chunk-server validates encryption options before opening upstream
  stores, so a missing key fails fast instead of after stores are
  dialed and the SIGHUP reload goroutine is running. The flag handling
  now uses the exported StoreOptions.EncryptionConfigured instead of
  hand-rolling the same check.
- inspect-chunks fetches the stored chunk size when the store is
  encrypted, not just when it is compressed, fixing sizes reported as
  0 for uncompressed encrypted stores.
- The SFTP store derives its converters once and hands the extension
  to the pooled connections instead of rebuilding the converter stack
  per connection.
- TestChunkServerEncryption restores the config globals it mutates.
- Update the converter pipeline description in CLAUDE.md.
The suffix-match, trim, and chunk-ID-parse sequence used to decide
whether a file belongs to a store was open-coded in five places with
small variations. It now lives in one chunkIDFromFilename helper next
to ChunkIDFromString, used by the local store's Verify and Prune, the
SFTP store's Prune, and the S3/GCS idFromName methods. The SFTP copy
previously matched the extension against the full remote path rather
than the base name, which the shared helper also corrects.

The chunk-server tests generate a random encryption key instead of
duplicating the library's test key fixture across packages, and the
key constants move next to the testKey helper in encrypt_test.go.
The chunk server goroutine reads the global config when opening its
upstream stores, while the test wrote the client config globals after
starting the server, usually hidden by the startup delay but flagged
by the race detector on a slow runner. The server address is now
picked before the server starts so the client config, which contains
the server URL, can be fully written first. startChunkServer is split
into freeLocalAddr and startChunkServerOnAddr to allow that ordering.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant