Files
Oxicloud/docs/plan/storage-key-rotation.md
Edouard Vanbelle f1f327a6c4 refactor(consistency): blobs_consistency reads only the database
`blobs_consistency` probed `blob_exists` once per row and, under
`?deep=true`, read and re-hashed every blob. `backend_consistency`
already reports the same `blob_missing_from_backend` from its
merge-join — so the probe was duplicated work that found strictly less
(a DB walk cannot see backend-only orphans by construction) at N round
-trips instead of one enumeration. Every scheduled sweep paid for it.

All three physical checks move to `backend_consistency`:

* `blob_missing_from_backend` was already there; the duplicate is gone.
* `blob_corrupted` / `blob_unreadable` hook the matched arm of the
  merge-join, which holds exactly the key pairs worth reading. Guarded
  by `in_range` so a pair past the horizon is not read twice, and
  `params.deep` is persisted on a fresh run and read back on resume so
  a paused deep scan does not silently continue shallow.

Deep mode belongs there because it is backend work end to end: the
only DB input is the hash. Keeping it in `blobs_consistency` forced
that tenant to carry a backend for one flag.

What remains is the half that needs no backend: `refcount_mismatch`
and its repair. The constructor drops from five parameters to two —
no backend, no storage_entries, no storage_path_fallback — and
`?storage=<name>` / `?deep=true` are now inert there, which the
job description says outright.

`affected_files` is needed by both tenants, so it moves to a shared
`blob_diagnostics` module rather than being copied.
`PROBED_STORAGE_PARAM` moves to `backend_consistency`: it was defined
in `blobs_consistency` and re-exported, which is backwards once the
DB-only tenant has no entry to scope. The create-grace window goes
with the probe — it existed to avoid flagging a blob whose bytes had
landed before its row, and the refcount comparison reads one
consistent snapshot.

Known cost: `backend_consistency` returns `backend_unenumerable` on
Azure and mid-migration, so on those configs missing bytes now go
unreported where the per-row probe caught them. That argues for the
Azure enumeration impl, not for keeping the probe.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 13:41:05 +02:00

620 lines
30 KiB
Markdown

# Plan — Storage encryption-key rotation
## Context
Today an encrypted storage entry declares a single key:
```env
OXICLOUD_STORAGE_s3_prod_BACKEND=s3
OXICLOUD_STORAGE_s3_prod_ENCRYPTION_KEY=<base64 32 bytes>
OXICLOUD_STORAGE_s3_prod_ENCRYPTION_CIPHER=aes-256-gcm # optional, defaults to aes-256-gcm
```
Blobs land at `<hash>.blob` as raw AES-GCM output (`nonce | ciphertext | tag`) —
no discriminating prefix. That key can never change. Any real deployment needs
to rotate keys — after a suspected leak, on a periodic policy, or after a staff
turnover. The current answer is "create a second entry and run a migration to
it," which:
* forces the admin to provision a second bucket / directory,
* churns object storage costs,
* and does not scale to routine rotations.
The proper fix is **in-place key rotation** inside the same entry. The storage
config holds a *list* of keys, the server writes with the newest one, reads
with any of them, and a background job re-encrypts every blob under the newest
key. When the job completes, the admin removes the old key from the list.
At the same time we solve two related on-disk problems that today's format
leaves open:
1. **No self-description.** A `<hash>.blob` today is ambiguous: could be raw
plaintext (unencrypted deployment) or raw AES-GCM output (encrypted
deployment). Reads guess based on the entry config; a misconfigured key or
a wrong-cipher default silently returns garbage.
2. **No path from encrypted to plaintext.** Once encryption is on for an
entry, the only way off is via a second entry and full migration — same
ergonomics problem as key rotation.
Both fall out of introducing a self-describing on-blob header (`v1`). The
object-key suffix stays `<hash>.blob` for both eras — the magic bytes at the
top of every v1 blob are the discriminator, so legacy files stay readable
byte-identically and new files coexist alongside them at the same suffix.
Migration is lazy.
Related memory (this plan supersedes it):
> **[Encryption key rotation in place — deferred, unsafe today]** — the previous
> "namespace object keys by encryption generation" direction (`<hash>.k2.blob`)
> is DROPPED in favour of the pair-list + v1-header approach below. That
> approach fought content-addressability by generation-namespacing per key;
> this one keeps a stable content-addressable object key and encodes generation
> in a compact on-blob header.
## Design
### The pair-list config
Replace the singular `_ENCRYPTION_KEY` + `_ENCRYPTION_CIPHER` with a single
comma-separated list of pairs on `_ENCRYPTION_KEY` alone:
```env
OXICLOUD_STORAGE_s3_prod_ENCRYPTION_KEY=aes-256-gcm:<base64 K1>,aes-256-gcm:<base64 K2>
```
Rules:
* **Format** — `[<cipher>:]<base64 key>` per pair, comma-separated. Whitespace
around commas / colons tolerated.
* **Cipher optional.** Only one real cipher (`aes-256-gcm`) exists today, so
`<base64 key>` on its own is legal and behaves as `aes-256-gcm:<key>`. When a
second cipher lands, the colon-prefixed form is the disambiguator.
* **The `none` cipher.** Legal, has no key material (`none:` — trailing colon
with an empty key). Enables migrating BOTH ways: encrypt a previously-plaintext
deployment (`none:,aes-256-gcm:<K>`), or decrypt an encrypted deployment
(`aes-256-gcm:<K>,none:`). Correctness relies on the v1 header (see
*Encrypted-blob format*) discriminating encrypted vs plaintext, NOT on
fallback ordering. A `none` pair by itself in the list is equivalent to
omitting `_ENCRYPTION_KEY` entirely (kept for symmetry).
* **Order matters. Last pair wins on writes.** For v1-header reads, the
`key_fp` in the header selects the exact pair; the list order is irrelevant
at read time. For legacy reads (see *Coexistence*), the head pair is used
because pre-v1 deployments had only one key by construction.
* **At least one pair.** Empty list → boot aborts.
* **Uniqueness.** Same key appearing twice = boot aborts (config drift smell).
`none` may appear at most once.
* **Fingerprint.** For each real-cipher pair, log a truncated SHA-256 of the
key material at boot (12 hex chars) so a swap-order accident is loud in the
audit stream. `none` logs as `none:—`.
### Retire `_ENCRYPTION_CIPHER`
`_ENCRYPTION_CIPHER` was added recently and has not shipped in a release. Drop
it entirely in the same slice — the pair-list makes it redundant, and the on-blob
v1 header carries the cipher choice explicitly per blob anyway. No back-compat
shim needed.
### Encrypted-blob format (v1 header)
Every v1 blob starts with a fixed self-describing header. Layout:
```
"OXCPT" 5 bytes — magic marker (0x4F 0x58 0x43 0x50 0x54)
<version> 2 bytes — big-endian u16; v1 = 0x0001
<key_fp> 8 bytes — sha256(key material)[..8]; identifies which pair
decrypts. For a `none` (plaintext) v1 blob this is
eight zero bytes and the fields below are absent.
<nonce> 12 bytes — random per blob (AES-GCM standard nonce length)
<ciphertext> N bytes — encrypted payload; same length as the plaintext
<auth_tag> 16 bytes — AEAD authentication tag
────
fixed overhead: 43 bytes for encrypted v1; 15 bytes for plaintext v1
```
* **The magic `OXCPT`** unambiguously distinguishes a v1 blob from legacy raw
bytes. It's the sole discriminator — no filename convention, no DB column,
the first five bytes tell the reader everything.
* **`<version>`** is the only field that can change format without touching
every blob. v1 is what this plan ships; v2 slots are reserved
(see *Forward compatibility*).
* **`<key_fp>`** eliminates guess-work on read. The pair-list lookup becomes
O(1) — no fallback tag-check loop. A key-fp with no matching pair means
`NoKeyForBlob` (500 with a clear message), NEVER silent garbage.
* **`<nonce>`** and **`<auth_tag>`** are the AES-GCM primitive's own outputs.
Their length is fixed by v1 = AES-256-GCM. If a future v2 uses a different
AEAD with the same shape (say ChaCha20-Poly1305, tag = 16 B, nonce = 12 B)
v2 can keep the layout; a truly-different AEAD triggers a new version.
* **Plaintext v1 blobs** (produced when the entry's head pair is `none`) use
the same magic + version + all-zero key_fp, then the raw plaintext bytes
directly (no nonce, no ciphertext framing, no tag). The header alone is
enough to say "this is plaintext v1", cleanly distinguished from encrypted
v1 and from legacy raw-plaintext.
**Why no CRC.** The AEAD tag covers ciphertext + nonce integrity. Content
addressability (`<hash>` = BLAKE3(plaintext)) covers whole-blob integrity for
either format. A CRC over the header adds nothing — a corrupt version byte
fails on decode; a corrupt key_fp fails to find a pair. Both fail loud.
### Coexistence with legacy blobs
Legacy and v1 blobs share the `<hash>.blob` object-key namespace. The magic
bytes at position 0 discriminate them safely:
| First 5 bytes | Interpretation | Read path |
|---|---|---|
| `"OXCPT"` | v1 blob | verify magic, dispatch on version + key_fp |
| anything else | legacy | apply entry config: raw plaintext if no encryption pair, single-key AES-GCM decode with the head pair if encryption is set (by construction pre-v1 used one key, and any pair-list produced by upgrading from a pre-v1 config keeps that key at the head) |
Collision probability of legacy bytes accidentally matching `"OXCPT"` at
position 0 is 2⁻⁴⁰ per blob. If a match does happen, the read continues into
the v1 path, fails at version/key_fp check with `UnsupportedBlobVersion` or
`NoKeyForBlob`, and returns a hard error. **Never silent corruption.** For
context: on a 10 M-blob deployment, expected number of collision hard-errors
across the lifetime of legacy files is ~10⁻⁵ blobs. Effectively never.
Properties this buys us:
* **No forced migration.** Legacy `<hash>.blob` files stay readable
indefinitely. Existing deployments upgrade to v1 code with zero
format-conversion work.
* **Lazy conversion on hot paths.** Any COW overwrite (WebDAV MOVE, PUT-over,
content-hash re-upload) naturally lands as v1 at the same object key.
* **Explicit conversion via `backend_rotate`.** The rotate job walks
`storage.blobs`, reads each blob via the magic-byte dispatch, and if the
blob is not already v1 with the head-pair key, PUTs it back as v1 with
the head pair — in place, same object key.
* **No schema changes.** Nothing new in `storage.blobs`; nothing for
`blobs_consistency`, `storage_migrate`, or any other sibling job to learn.
### Legacy-pair guardrail
Once any legacy blob remains on disk, the pre-v1 single key that produced
those blobs MUST remain in the pair-list at the head position (or in the list
somewhere the legacy read path can find it). Removing it early breaks every
legacy read.
Guardrail:
* Boot logs a `warn` line if any pair-list has more than one entry AND the
head pair's fingerprint differs from the second entry's — signals "you
added new keys but haven't rotated legacy blobs yet".
* A **legacy-blob counter** is surfaced in the admin panel per storage entry.
> **Retarget (post-split).** This plan names `blobs_consistency` as the
> host for the magic-byte branch throughout. That is no longer the right
> tenant: `blobs_consistency` became database-only and opens no backend,
> while `backend_consistency` owns every physical check and already holds
> the enumeration. Read `backend_consistency` wherever the sections below
> say `blobs_consistency`. Nothing else about the design changes — the
> magic-byte check still rides along on an existing walk, and still lands
> in the run's `stats` bag.
The counter is maintained by that scan: during its normal walk it
branches on the magic-byte check and records the legacy count as a run
statistic on `jobs.recoverable_runs` (existing surface, no schema hit). The
admin panel reads the most recent count and displays it. Refresh cadence is
whatever consistency scan cadence the deployment has (weekly by default; on
demand from the admin panel).
* The *"Rotation complete — safe to remove the old key"* hint appears in the
entry card only when the last `backend_rotate` run completed with zero
findings AND the most recent consistency scan reported zero legacy blobs.
* Nothing enforces removal at code level — the admin is trusted, given a
clear signal, and warned.
### Read path
Per blob read:
1. Fetch `<hash>.blob` from the backend.
2. Check first 5 bytes.
3. If `"OXCPT"` → v1 read path:
* Read `<version>`. Not `0x0001` → `UnsupportedBlobVersion`.
* Read `<key_fp>`. If zero → plaintext-v1; return raw bytes (post-header
payload). Else find the pair whose `sha256(key)[..8]` matches; not
found → `NoKeyForBlob`; found → AES-GCM decrypt with `<nonce>` /
`<ciphertext>` / `<auth_tag>` and return plaintext.
4. Otherwise → legacy read path:
* If entry has any real-cipher pair, attempt AES-GCM decrypt with the
head pair's key; return plaintext on success, `DecryptFailed` on tag
failure.
* If entry has only `none` (or no pair at all), return raw bytes as
plaintext.
Never falls through silently. Every failure is a distinct typed error the
handler can map to a 500 with an actionable message.
### Write path
1. Always writes to `<hash>.blob`.
2. Head pair is `none` → write v1 header (magic + version + zero key_fp) +
raw plaintext.
3. Head pair is a real cipher → compute AEAD; write v1 header (magic +
version + key_fp) + nonce + ciphertext + auth_tag.
v1 code never produces a legacy-format blob. Any leftover legacy blobs on
disk pre-date the v1-code deployment.
### The rotation job
New `RecoverableJobHandler` tenant, `backend_rotate`. Mirrors
`backend_migration`'s shape:
* **Iterates `storage.blobs`** in hash-lex order. Cursor is the last-processed
hash (64 hex chars). Same cursor encoding as `backend_migration` and
`blobs_consistency`.
* **Per blob:**
1. Fetch `<hash>.blob` and dispatch via the standard read path.
2. Decide if a rewrite is needed based on what actually decoded:
* Legacy blob (no v1 magic) → always rewrite (upgrade to v1 with head
pair).
* v1 encrypted, decrypted under a pair-index other than head → rewrite
(key rotation).
* v1 encrypted, already under head — skip.
* v1 plaintext, head pair is `none` — skip.
* v1 plaintext, head pair is a real cipher — rewrite
(encrypt-in-place upgrade).
* v1 encrypted, head pair is `none` — rewrite
(decrypt-in-place downgrade).
3. Write via the standard v1 write path — same object key, atomic
replace.
4. Checkpoint. On per-blob failure, record a `rotation_failed` finding
with severity `data_loss` (bytes may not have crossed), continue.
* **Concurrency-safe by construction.**
* A concurrent upload during rotation writes v1 with the head key. The
rotate walk will short-circuit on that hash if it reaches it later.
* A COW overwrite is the same story.
* The v1 write is atomic at object-storage level (S3 replace, Local
rename-into-place). A concurrent reader sees either state.
* No readonly mode. This is a critical improvement over
`backend_migration`: rotation is per-blob idempotent, so we don't need
to freeze writes.
* **Restart-survivable** — same boot-time sweep as every other recoverable
handler.
* **`?deep=true` unused** — rotation has no "slow variant" mode. Parameter
accepted for uniformity, ignored.
### Admin UX
In the admin storage-panel entry card, add a **Rotate encryption key** action.
Preconditions:
* Entry has encryption enabled with ≥ 2 pairs OR entry has legacy blobs
outstanding OR the pair-list has otherwise changed since the last rotation.
* Otherwise the button is disabled with the tooltip *"Add a second pair in
`OXICLOUD_STORAGE_<name>_ENCRYPTION_KEY` first, or wait for legacy blobs to
accumulate — nothing to rotate right now."*
Clicking the button dispatches the `backend_rotate` job for that entry. The
job's progress rides on the same `X-Server-Status` header infrastructure the
maintenance banner uses — but this time WITHOUT engaging read-only mode.
Banner variant reads *"Rotating encryption key on `<entry>` — X% (Y / Z
blobs). All operations continue normally."* and disappears on completion.
The entry card shows a **legacy-blob counter** sourced from the most recent
`blobs_consistency` run: *"N legacy-format blobs remain (upgrade included in
next rotation)"*. Refresh-on-demand button next to it triggers a targeted
`blobs_consistency` scan (already available via the admin surface). The
*"Rotation complete — safe to remove the old key"* hint appears only when
N = 0 and the last `backend_rotate` run completed with zero findings.
### Removing the old pair
Not automated. The admin edits `.env`, restarts. Rationale:
* We do not want the running server to silently mutate its own `.env`.
* A retained old pair is a benign cost (nothing hits it on read since
`key_fp` lookup is O(1)).
* Explicit human step matches the "add a new pair → restart" symmetry.
## Deployment flow
### First-time upgrade to v1
Zero admin work required. On upgrade:
* v1 code deploys.
* New blobs are written in v1 format at the existing `<hash>.blob` object
key.
* Existing legacy blobs stay readable via the magic-byte dispatch — the
legacy read path is preserved verbatim.
* Admin can optionally trigger a `backend_rotate` run to consolidate every
legacy blob into v1 format. Not required — legacy blobs migrate
opportunistically via COW overwrites and stay readable indefinitely
otherwise.
### Rotating an encryption key
The user-facing recipe (goes verbatim into `docs/guide/backend-storage.md`):
```
1. Generate a new key:
openssl rand -base64 32
2. Append it to the entry's key list. Order matters — the NEW key goes LAST:
OXICLOUD_STORAGE_s3_prod_ENCRYPTION_KEY=aes-256-gcm:<OLD>,aes-256-gcm:<NEW>
3. Restart OxiCloud. New uploads are now encrypted with the new key; existing
blobs still decrypt with the old one.
4. In the admin panel, click "Rotate encryption key" on the entry. This
dispatches a background job that re-encrypts every existing blob under
the new key AND upgrades any remaining legacy-format blobs to v1. All
operations keep working during rotation.
5. Wait for the job to complete (progress shows in the top banner and in
the Jobs admin page).
6. Remove the OLD key from the list:
OXICLOUD_STORAGE_s3_prod_ENCRYPTION_KEY=aes-256-gcm:<NEW>
7. Restart OxiCloud. Rotation is complete.
```
### Encrypting a previously-plaintext deployment
```
1. Generate a new key (as above).
2. Add it AFTER `none`:
OXICLOUD_STORAGE_local_main_ENCRYPTION_KEY=none:,aes-256-gcm:<K>
3. Restart. New uploads are encrypted; existing plaintext blobs stay readable.
4. Run `backend_rotate` to encrypt existing blobs in place.
5. Remove `none:` from the list; restart.
```
### Decrypting an encrypted deployment
```
1. Add `none:` AFTER the current encryption key:
OXICLOUD_STORAGE_local_main_ENCRYPTION_KEY=aes-256-gcm:<K>,none:
2. Restart. New uploads are plaintext; existing encrypted blobs stay readable.
3. Run `backend_rotate` to decrypt existing blobs in place.
4. Remove the key pair, keep `none:` only (or drop `_ENCRYPTION_KEY` entirely);
restart.
```
## Testing strategy
**Most of this ships as Rust tests, not Hurl.** The rotation surface is
byte-level, cryptographic, and stateful across restart boundaries — none of
which Hurl can meaningfully assert. Push almost everything down into
`#[cfg(test)]` blocks alongside the code, following the codebase's existing
inline-tests convention (see AGENTS.md — "tests are primarily `#[cfg(test)]`
modules within source files").
**What lives in Rust (unit + integration):**
* **Pair-list parser.** Unit tests in `config.rs` on `_ENCRYPTION_KEY` parsing:
1-pair, 2-pair, cipher-optional shape, `none:` alone, `none:,aes:K`,
`aes:K,none:`, whitespace tolerance, duplicate rejection, multiple-`none`
rejection, empty-list rejection, retired-`_ENCRYPTION_CIPHER` rejection,
base64 length validation. Table-driven; fast.
* **v1 header round-trip.** Unit tests in
`encrypted_blob_backend.rs::tests`: encrypt payload → verify header bytes
(magic, version, key_fp) → decrypt → assert plaintext identity. One test per
format flavour (encrypted, plaintext-with-`none`-head). Verify `<hash>` =
BLAKE3(plaintext) with no offset for plaintext-v1 (the "recovery tool"
invariant).
* **Legacy read-path preservation.** Fixture: a hand-crafted raw AES-GCM
ciphertext (`nonce | ct | tag`) produced by the pre-v1 code path. Assert
that magic-byte dispatch falls through to the legacy branch and decrypts
correctly. Fixture stays in-tree as `tests/fixtures/legacy_blob.bin` so
regressions on the legacy path can never sneak through.
* **`OXCPT` collision on random data.** Property test: generate random N-byte
payloads, feed through legacy read path. Assert the ~2⁻⁴⁰ magic-collision
case fails with `UnsupportedBlobVersion` or `NoKeyForBlob` — hard error,
never silent misread. Keeps the "collisions can't silently corrupt" claim
in the plan honest.
* **Rotation decision tree.** Unit tests on the per-blob `decide()` helper of
`backend_rotate_service.rs`. All six cases from *The rotation job* section
as separate tests with clear names (`legacy_upgrades_to_v1`,
`v1_encrypted_under_head_skips`,
`v1_encrypted_under_older_pair_rewrites`,
`v1_plaintext_with_none_head_skips`,
`v1_plaintext_encrypts_when_head_is_cipher`,
`v1_encrypted_decrypts_when_head_is_none`).
* **Recoverable-job round-trip.** Integration test in
`backend_rotate_service::tests` using the existing recoverable-run harness:
seed N blobs (mix of legacy + v1-under-old-key), trigger rotation, assert
every blob ends v1-with-head, `format_generation` (if we add it later) or
the consistency-scan count reports zero legacy remaining, findings=0.
* **Crash recovery.** Same harness: interrupt mid-run at cursor position K,
restart, assert resume from K and eventual completion with correct final
state. Same discipline `backend_migration` already uses.
* **Concurrency safety.** Test that a `put_blob` call during a rotation
targeting the same hash produces exactly one v1 blob at end-state (either
the rotate's or the concurrent write's — both are head-format so the final
state is indistinguishable). Verifies the "per-blob idempotent" claim.
* **Config-restart semantics.** Table-driven test on the pair-list state
machine: start with pair-list [K1], add K2 → [K1,K2], run rotation,
drop K1 → [K2], assert every blob still decrypts. Exercises the "safe to
remove old key" transition end-to-end without going through the admin UI.
**What Hurl covers (thin — the API surface, not the mechanics):**
* Trigger endpoint auth: `POST /api/admin/storage/entries/{name}/rotate` is
admin-only (403 for non-admins, 401 for unauthenticated).
* Trigger endpoint preconditions: 400 or similar when pairs < 2 AND no
legacy blobs exist AND pair-list hasn't changed.
* Trigger endpoint concurrency: second POST while a run is Active returns
409.
* Progress header: `X-Server-Status` includes a `rotation` payload during a
running job and drops it on completion.
Hurl does NOT try to:
* Inspect on-disk bytes.
* Restart the server with a new pair-list.
* Seed a legacy blob directly.
* Verify decryption after old-key removal.
Those all belong in Rust tests where we can hold the pool, the backend, and
the config in the same test's memory.
**Test data.** Legacy-blob fixtures are byte-frozen (`tests/fixtures/*.bin`)
and committed. Don't regenerate them from live code — the whole point is that
they were produced by pre-v1 code and won't ever again be. Regeneration
scripts (kept outside `tests/`) are OK for one-time refreshes if the format
changes.
## Slices
Same discipline as `storage-multi-entry.md`. Each slice is a mergeable
increment; no slice depends on a later one.
### Slice K1 — Pair-list config parsing
**Scope.** Config layer only. No behaviour change on the write / read path
yet — the parser produces `Vec<KeyPair>` and the existing code keeps calling
`pairs.last()`.
* `NamedStorageEntry.encryption` becomes `Option<Vec<KeyPair>>` where
`KeyPair = { cipher: CipherKind, key_material: Option<[u8; 32]> }`.
`CipherKind` = `{ AesGcm256, None }`; `None` carries no key.
* Parser: split on `,`, per-pair split on `:` (1 or 2 parts), decode base64
when a real cipher, reject empty list, reject duplicates, reject multiple
`none`.
* Retire `OXICLOUD_STORAGE_<N>_ENCRYPTION_CIPHER` — parser errors on it with
guidance to move the cipher into the pair.
* Boot logs each pair's fingerprint (`sha256(key)[..12]` for real ciphers,
`—` for `none`) and marks the head. Warn line if head fp differs from any
non-head fp (rotation window signal).
* Docs: `example.env`, `docs/config/env.md`, `docs/guide/backend-storage.md`
updated with the pair syntax and the three recipes (rotate / encrypt /
decrypt).
**Exit criteria.** Boot with a 1-pair config behaves identically to today.
Boot with a 2-pair config succeeds and logs both fingerprints. Boot with
`_ENCRYPTION_CIPHER` alongside `_ENCRYPTION_KEY` fails with a migration hint.
### Slice K2 — v1 read/write paths in `EncryptedBlobBackend`
**Scope.** The encrypted-backend decorator learns the v1 header format. Reads
dispatch on magic bytes; writes always emit v1 headers. Legacy read path
preserved verbatim. `blobs_consistency` gains a magic-byte branch to track
the legacy-blob count.
* `EncryptedBlobBackend` refactored around `BlobFormat::V1` writer + reader.
Legacy path preserved but read-only from new code.
* Read dispatches on magic; write always emits v1 header at `<hash>.blob`.
* `blobs_consistency`: during its normal walk, per-blob magic-byte check;
legacy count recorded on the run's `stats` JSON. No schema hit — reuses
the existing `stats` bag on `jobs.recoverable_runs`.
* `storage_migrate`: no changes needed. It copies raw bytes between backends;
format is preserved on the target automatically because the object bytes
are opaque to it.
**Exit criteria.** A brand-new deployment writes only v1 blobs. An upgraded
deployment reads existing legacy blobs and writes new v1 blobs at the same
object-key. `blobs_consistency` reports a legacy-blob count in its run stats.
### Slice K3 — The `backend_rotate` recoverable job
**Scope.** New handler, admin-triggered, iterates blobs, per-blob decision
tree (legacy → v1 upgrade, v1 with old key → v1 with head key, plaintext ↔
encrypted where applicable), records findings.
* New file `src/infrastructure/services/backend_rotate_service.rs`.
* Registered in `JobRegistry` as `backend_rotate`. Runs on the same
recoverable-runs engine (crash recovery, cursor persistence, pause/resume).
* Trigger endpoint: `POST /api/admin/storage/entries/{name}/rotate`.
Requires admin. Refuses if no work would happen (all blobs already at
head format + head key). Refuses if a `backend_rotate` or
`backend_migration` run is already Active for any entry.
* Per-blob decision tree per *The rotation job* section above. In-place
atomic replace at the same `<hash>.blob` object key.
* No readonly mode engaged. `X-Server-Status` header payload gains a
`rotation` field alongside `migration`.
**Exit criteria.** On a filled sandbox: (1) legacy-only entry rotates to
v1; (2) encrypted entry with 2 pairs rotates so head-pair-only remains
decryptable; (3) plaintext entry with `none,cipher` head rotates to
encrypted-v1; (4) encrypted entry with `cipher,none` head rotates to
plaintext-v1. Each round-trip verified by dropping the retired pair from
config and successfully reading every blob.
### Slice K4 — Admin panel action + banner + docs
**Scope.** UI wiring, banner variant, guide docs, legacy-blob counter.
* Entry card: **Rotate encryption key** button + tooltip states as above.
* Legacy-blob counter chip on each entry card. Sources the count from the
most recent `blobs_consistency` run's stats (already surfaced in the Jobs
admin page). Refresh button triggers a fresh scan.
* `ReadOnlyBanner.svelte` gains a third variant `variant="rotating"` — same
shape, milder tone (info not warning), copy makes it clear that operations
continue.
* Server-status header payload gains `rotation?: {entry, migrated, total,
percent}` alongside the existing `migration?:` field. `readonly` stays
false during rotation.
* `docs/guide/backend-storage.md`: new **Rotating an encryption key**,
**Encrypting a plaintext deployment**, and **Decrypting an encrypted
deployment** sections with the recipes above.
* `docs/config/env.md`: pair syntax section under `_ENCRYPTION_KEY`.
* Admin panel *"Rotation complete — safe to remove the old key"* hint gated
on findings=0 AND legacy count=0.
**Exit criteria.** Round-trip on a real deployment (Ed's OVH S3): add pair
→ restart → rotate → verify blobs (including legacy) → remove old pair →
restart → blobs still readable.
## Forward compatibility (v2 and beyond)
The `<version>` field is the single load-bearing knob for future format
changes. Reserved slots:
* `0x0001` — this plan. AES-256-GCM, server-side keys, individual blob
integrity via AEAD tag + content-hash.
* `0x0002…0x00FF` — reserved for server-side format bumps: new AEAD,
chain-authenticated CDC chunks (either *manifest HMAC* — no header changes,
`chunk_manifests` gains a server-computed HMAC over the ordered chunk-hash
list — or *Merkle root in header* — v2 grows to carry `manifest_root` +
`chunk_index`, letting any streamed chunk be verified in isolation). Both
defend against manifest reorder / truncation / injection attacks that
content-addressability alone doesn't cover.
* `0x0100…0x01FF` — reserved for client-side encryption (E2E) variants.
`<key_fp>` becomes the client-key fingerprint; the server can no longer
decrypt and passes ciphertext through the streaming path unchanged.
v1 and v2 coexist in the same storage indefinitely — the magic-byte read
dispatch handles arbitrary versions at position 5-6. Migration between
generations reuses `backend_rotate`'s pattern: rewrite each blob with the
new-generation writer, in-place at the same object key.
## Non-goals
* **Asymmetric / KMS-backed keys.** Out of scope. Symmetric AES-256-GCM only.
A KMS-backed variant is a separate slice built on top of this one.
* **Chunk-chain integrity / manifest tampering defense.** Recognised as a
real gap (chunk reorder / truncate / inject via DB write). Deferred to the
v2 header slice — see *Forward compatibility*.
* **Client-side encryption (E2E).** Same — v2 slot reserved, separate slice.
* **Automatic old-key removal.** Explicit human step, see above.
* **Rotation of a key on the "in-transit" side** (client → server TLS).
Handled by the reverse proxy; unrelated.
* **Per-blob key derivation** (KDF from a master key + blob hash). A
legitimate hardening path but orthogonal to the rotation story; folds in as
a future slice under this same pair-list config.
* **Rollback safety across format generations.** If v1 code is rolled back
to pre-v1 after new v1 blobs have been written, old code will 500 on those
blobs (AES-GCM tag fails on OXCPT-prefixed bytes). Not addressed by this
plan; the safety net is restore-from-backup. Deploying an
incompatibility-breaking format change is a pre-planned event, not a hot
rollback scenario.
## Open questions
* **Should we throttle the rotate job?** Same question `backend_migration` had.
Answer: not in v1. If throughput bites, add a `_ROTATE_MAX_MB_PER_SEC` on
the entry later.
* **Should rotation be idempotent under repeat trigger?** Yes. Running it a
second time with the same config is a no-op walk (every blob is already at
head format + head key).
* **Should we ship a `format_generation` column on `storage.blobs` as a
query-optimizer?** No in v1. Consistency-scan stats cover the "how many
legacy blobs remain" question at admin cadence, and per-read magic-byte
dispatch is a 5-byte compare. If a real hot-path or admin-panel latency
need emerges, the column is a small back-fill migration to add later.
* **Should the legacy-blob counter block key removal at code level?** No —
keeping the trust model consistent with "admin edits .env, we don't fight
them" is the current default. The counter + hint is enough. If real
incidents happen we can escalate to a hard guard later.