Skip to content

feat(storage): encrypt objects at rest when an operator supplies a key - #109

Merged
BryanFRD merged 1 commit into
mainfrom
feat/encryption-at-rest
Aug 16, 2026
Merged

feat(storage): encrypt objects at rest when an operator supplies a key#109
BryanFRD merged 1 commit into
mainfrom
feat/encryption-at-rest

Conversation

@BryanFRD

@BryanFRD BryanFRD commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Closes #44.

LFSX_ENCRYPTION_KEY_FILE points at a file of 32-byte keys as hex. Unset, nothing changes. A path rather than the key itself for the reason the issue gives: a key in the environment is in the pod spec, in docker inspect, and in every log that dumps the environment.

The server refuses to start if the file is missing or unreadable. A server that quietly wrote plaintext because a Secret failed to mount is the one failure this must never have, since nothing downstream would notice and the objects written in the meantime are exactly the ones an operator believed were covered.

It is version 2 of the format compression already had

That format solved most of this before encryption existed: a magic header so a plain store and a framed one are the same store, the plaintext length in the header, four-megabyte frames decoded independently, and an index. So encryption is not a second layer over the file, it is sealed frames inside the one that was there.

  • The oid is still the digest of the plaintext. The upload hashes bytes as they stream and encrypts after, so the name keeps meaning what every other part of the server addresses objects by, and deduplication is untouched.
  • Content-Length comes from the header, which the compression work already made true. The issue calls this the detail most likely to be missed; it was already load-bearing.
  • A store can hold raw, compressed and encrypted objects at once, so turning the key on is not a flag day.
  • level became Option<i32> rather than growing a special case, so encryption without compression is a configuration rather than a code path.

The crypto, and why each choice

ChaCha20-Poly1305, because ARM servers and a Raspberry Pi are supported targets and it does not lean on AES-NI.

A key per object, derived from the master key and a 16-byte salt stored with it. The alternative was a random nonce prefix per object, which puts a birthday bound on how many objects one key may cover. Deriving instead means the nonce is only ever a frame counter and two objects cannot collide on one, by construction rather than by probability.

A key is identified by a hash of itself, not by a number an operator assigns. An id can then never come to name a different key than the one it was written with, and rotation is appending a line rather than remembering which number is next. Every key in the file reads, the first writes, so the first rotation is not a re-encryption of the store.

Each frame is bound to where it was written: its index, whether it is the last, and the object id are authenticated. That refuses three rearrangements someone with write access to the disk could otherwise make without touching a byte inside any frame — reordering, truncation to a shorter object that still opens, and moving one file on top of another in the shared content store. There is a test per case.

Neither Keyring nor ObjectKey derives Debug. The tests format the error rather than the value.

Two things that are not in it, deliberately

Bucket mode ignores the key, and says so at startup, exactly as it now ignores compression: both formats are read through the file the codec opens to find its index, and a bucket key is not that. This is the case that most deserves encryption, so it is a gap rather than a decision. Filed as #110 — it is the same seam compression needs, not a second piece of work.

No migrate command. Objects written before the key stay plaintext and keep being served; re-pushing converts them. The issue leaves this open; rewriting a store in place is a separate change with its own failure modes, and the format was built so it is not required.

Chart

Mounts an existing Secret rather than taking the key as a value, since a Helm value lands in the release secret. defaultMode: 0440 and not 0400: a projected secret is owned by root, fsGroup gives the group to 65532, and owner-only read looks tighter while leaving the server unable to open its own key.

Tests

209 pass. New ones cover the key file and rotation, the three rearrangements above, ranges over encrypted objects, both kinds readable side by side, an encrypted object refused rather than served as ciphertext by a keyless server, the audit reading encrypted objects through their own bytes, and that the plaintext is genuinely not on the disk.

The README states the threat model in the words the issue asks for, including that this does not protect against anyone who has the running server, and points at LUKS or an encrypted volume first.

Copilot AI lite review requested due to automatic review settings August 16, 2026 18:16

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@BryanFRD
BryanFRD enabled auto-merge (squash) August 16, 2026 18:16

@ferrfleet ferrfleet Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the full diff (codec.rs, crypt.rs, storage/mod.rs, rewrite.rs, chart, config/error wiring, and the new test suites).

The design holds up under adversarial reading:

  • oid used in AAD is always the caller's expected/path-derived oid, never read off the file — so the "move one file onto another" attack is correctly caught by an AAD mismatch (Error::Tampered), not silently swallowed.
  • Per-frame (index, last, oid) binding correctly rejects reorder/truncation/move; traced through the last-flag math (index+1 == frames) and it lines up with what seal/open were called with at write time.
  • Per-object key derivation (master + random 16-byte salt via blake3 derive_key) means the nonce only ever needs to be a frame counter — no birthday-bound nonce reuse across objects, confirmed by two_objects_under_one_key_never_share_a_keystream.
  • Frame-flush logic changed from >= to > so "is this the last frame" is knowable before sealing — checked the boundary cases (exact multiple of FRAME, empty object) and they still produce the same frame counts as before.
  • Backward/forward compat (unkeyed objects still read, keyed store still serves both formats side by side, bucket mode explicitly disclaims both compression and encryption) all have direct tests.

Nits (non-blocking):

  • Keyring/ObjectKey hold raw key bytes in a plain Vec/array with no zeroize on drop — out of scope per the stated threat model (doesn't protect against anyone with the running server), but worth a follow-up if that threat model ever tightens.
  • The file-reordering tamper case has a full Framed-level integration test, but the truncation case is only exercised at the ObjectKey::open unit-test level, not through a real truncated file via Framed::open. Given the AAD math is identical either way this is very unlikely to hide anything, just noting the coverage asymmetry.

No blocking findings. CI is green (SonarQube/Check still running at time of review, nothing failing).

@BryanFRD
BryanFRD merged commit aa40efe into main Aug 16, 2026
25 checks passed
@BryanFRD
BryanFRD deleted the feat/encryption-at-rest branch August 16, 2026 18:19
@github-actions

Copy link
Copy Markdown

SonarQube — aucune nouvelle issue

Comparaison entre le projet bac à sable de cette PR et la branche par défaut : SonarQube Community n'analyse pas les PR, ce delta est calculé côté CI. Détail

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.

Encrypt objects at rest when an operator supplies a key

2 participants