Add optional chunk encryption to stores#371
Open
folbricht wants to merge 10 commits into
Open
Conversation
This was referenced Jul 8, 2026
Closed
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.
20ad549 to
f4c890e
Compare
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.pruneandverifyonly 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, andencryption-algorithminstore-optionsDESYNC_ENCRYPTION_KEYenvironment variable as fallback for the key, to keep it out of config fileschunk-server --encryption/--encryption-keyto 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 errorAlso included from the rebase: the converter interface now determines chunk file extensions (
storageExtension()), replacing theCompressedChunkExt/UncompressedChunkExtconstants, and the now-unusedhasCompression()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.