---
title: "Encryption at Rest (opt-in envelope encryption)"
description: "This guide covers NovaFabric's optional application-layer envelope encryption for capsule payloads in the object capsule store (ADR-0185)."
canonical: https://novafabric.ai/docs/ops/encryption-at-rest/
last_updated: 2026-10-23
source: https://github.com/MSKazemi/novafabric/blob/main/docs/ops/encryption-at-rest.md
---

# Encryption at Rest (opt-in envelope encryption)

This guide covers NovaFabric's **optional** application-layer envelope
encryption for capsule payloads in the object capsule store
([ADR-0185](https://novafabric.ai/docs/decisions/)).

**Status: experimental** (v0.61, shipped 2026-07-16) — and **never the
default**. Out of the box, NovaFabric's at-rest posture is *integrity, not
confidentiality*: DSSE envelopes, Merkle logs, and WORM Object Lock make
evidence tamper-evident, while confidentiality is delegated to disk, database,
and bucket encryption. That remains the recommended default. Turn this feature
on only when you need application-held confidentiality on top — and understand
the trade first:

> **Lose the KEK, lose the evidence.** Envelope encryption trades
> confidentiality for a key-availability risk. A destroyed or unreachable key
> makes every encrypted capsule permanently unreadable — inside its WORM
> retention window, with no recovery path. Fold the KEK into your backup and
> DR posture (see the [backup & restore runbook](https://novafabric.ai/docs/ops/backup-restore/)) before
> enabling this in anything you care about.

---

## 1. How it works

Per-object envelope scheme (`src/novafabric/trust/envelope_encryption.py`):

1. Every `put` generates a **fresh random 256-bit DEK** (data-encryption key)
   and encrypts the payload with **AES-256-GCM** (96-bit random nonce), using
   the existing `cryptography` dependency — no new dependency. Two
   encryptions of identical plaintext always produce distinct ciphertexts and
   distinct wrapped DEKs.
2. The DEK is **wrapped by a KEK** (key-encryption key) held by the
   operator's key backend, via the additive `KeyWrappingBackend` capability
   (`wrap_key` / `unwrap_key` / `kek_ref`). The wrapped DEK travels inside
   the stored envelope; the KEK never does.
3. **Encrypt-before-WORM (normative ordering).** The serialized ciphertext
   envelope is what the WORM adapter stores; the SHA-256 handed to the
   backend (checksum header / CAS gate) is recomputed **over the stored
   encrypted bytes**. `content_sha256` inside the envelope is likewise
   computed over the ciphertext — so integrity verification (hashes, Merkle
   leaves, WORM conformance) **never requires decryption or KMS access**.
4. **Envelopes are bound to their object (v2, ADR-0290).** Every new
   envelope authenticates its object-store key as AES-GCM *associated data*
   and records `"envelope_version": 2`. An envelope copied to a different
   key — by anyone with write access to the bucket — fails authentication
   with `BlobAuthenticationError` instead of decrypting to another object's
   content. The binding covers the object key only, never the KEK reference,
   so re-wrapping a DEK under a new KEK does not require re-encryption.
   Envelopes written before ADR-0290 (no `envelope_version` field, "v1")
   still decrypt; each such read logs a warning, because a v1 envelope is not
   bound and could have been moved.
5. **Reads fail closed on plaintext.** The store detects the envelope by its
   schema marker fields and decrypts. A stored object that is **not** an
   envelope raises `PlaintextObjectRefusedError`: the reader cannot tell
   "written before encryption was enabled" from "plaintext substituted by
   someone with write access to the store". See
   [§2.1](#21-migrating-a-store-that-already-holds-plaintext) for stores that
   genuinely hold pre-encryption objects.
6. **Chain-log objects are never encrypted.** They are integrity metadata,
   not capsule payloads — exactly as ADR-0031 excludes them from WORM.

Tampering is loud, with named exceptions: a ciphertext that fails its
recorded hash raises `CiphertextIntegrityError` *before* any key material is
touched; a tampered wrapped key raises `DekUnwrapError`; AES-GCM
authentication failure raises `BlobAuthenticationError`.

## 2. Enabling it (store wiring)

Opt-in requires **both** environment variables on the process that owns the
object capsule store:

```bash
# 256-bit KEK: raw 32 bytes, or 64 hex characters, in a local file
head -c 32 /dev/urandom > /secure/nova-kek.bin
chmod 600 /secure/nova-kek.bin

export NOVA_OBJECT_STORE_ENCRYPTION=1        # truthy: 1/true/yes/on
export NOVA_OBJECT_STORE_KEK_PATH=/secure/nova-kek.bin
```

With both set, `make_adapter` wraps whichever WORM backend you configured
(`s3`, `minio`, `ceph_rgw`, `azure_blob`, `gcs`, or `local` — an in-memory test adapter, not WORM) in the encrypting adapter,
and every capsule payload is envelope-encrypted before the WORM write. Absent
that configuration, behavior is **byte-for-byte unchanged**.

The local-file KEK path uses the NovaSeal `LocalSigningBackend` wrap
capability — suitable for dev/test parity and self-managed deployments where
you control the file's lifecycle.

### 2.1 Migrating a store that already holds plaintext

Since ADR-0290, a read of a non-envelope object from an encrypted store
fails closed (`PlaintextObjectRefusedError`) — chain-log objects under
`_capsule_log/` excepted, as they are never encrypted. If you enabled
encryption on a store that already holds capsules, opt in explicitly for the
migration window:

```bash
export NOVA_OBJECT_STORE_ALLOW_PLAINTEXT_READS=1   # legacy plaintext reads
```

The process logs a warning at startup and on every plaintext object it
returns. New writes are still encrypted and bound. Unset the variable once
every pre-encryption object has been re-ingested (or has aged out of
retention) — while it is set, plaintext substitution by a storage operator
goes undetected by the read path.

#### 2.1.1 Narrower alternative: a digest-pinned legacy inventory (experimental, ADR-0295)

Instead of admitting *every* non-envelope object, record the objects that
existed when you enabled encryption and admit only those, byte-for-byte. Build
the inventory once, at the cut-over, over the **bare** (unencrypted) adapter:

```python
from novafabric.object_capsule_store.legacy_inventory import build_legacy_inventory

# inner_adapter: your WormAdapter built WITHOUT the encryption env vars set
inv = build_legacy_inventory(inner_adapter, prefixes=["capsules/"])
open("/secure/legacy-inventory.json", "w").write(inv.to_json())
```

```bash
export NOVA_OBJECT_STORE_LEGACY_INVENTORY=/secure/legacy-inventory.json
export NOVA_OBJECT_STORE_LEGACY_INVENTORY_SHA256=$(sha256sum /secure/legacy-inventory.json | cut -d' ' -f1)  # optional pin
```

Each listed key is pinned to the SHA-256 of its stored bytes. A listed object
whose bytes still match is returned; a listed object whose bytes changed
(substitution) and any unlisted plaintext are refused with
`PlaintextObjectRefusedError`. A missing, malformed or pin-mismatched
inventory **refuses to start**. Keep the file with the KEK, never in the
object store. If `NOVA_OBJECT_STORE_ALLOW_PLAINTEXT_READS` is also set it
still wins (startup warning) — unset it to let the inventory decide.

Why not a timestamp watermark ("objects written before date X are allowed")?
The only per-object time the reader can see is the store's own
`LastModified`/mtime, and whoever can write to the store can set it, so a time
rule is one the attacker satisfies by backdating.

**Limit:** an inventory built *after* someone substituted objects pins the
substitutes. Build it at the cut-over, from a store you trust. Memory is
O(listed objects); for very large legacy stores, re-ingest instead.

#### 2.1.2 Strict mode for unbound v1 envelopes (experimental, ADR-0295)

```bash
export NOVA_OBJECT_STORE_REFUSE_V1_ENVELOPES=1
```

Refuses every pre-ADR-0290 (v1, unbound) envelope with
`LegacyEnvelopeRefusedError` before any key unwrap, unless the legacy
inventory pins it. This closes the gap ADR-0290 left open: an old v1 envelope
copied onto a new key. Default off. Read counters
(`EncryptingAdapter.read_counters()`: `legacy_envelope_reads`,
`plaintext_reads`, `inventory_reads`, `legacy_refusals`) are in-process only;
they reset on restart.

### Per-tenant KEKs (ADR-0243, experimental)

Optionally, each tenant's capsules can wrap their DEKs under that tenant's
**own** KEK instead of the shared one — key compromise becomes tenant-scoped,
and removing one tenant's KEK makes exactly that tenant's data
cryptographically unreadable (the offboarding/erasure semantic, enforced
fail-closed with an error naming the tenant):

```bash
mkdir /secure/tenant-keks
head -c 32 /dev/urandom > /secure/tenant-keks/acme.kek     # one file per tenant
export NOVA_OBJECT_STORE_TENANT_KEK_DIR=/secure/tenant-keks
```

Tenants **without** a `<tenant>.kek` file keep using the default KEK — zero
change for existing deployments — and every pre-existing envelope stays
readable forever (the `tenant_key_id` field is additive). The registry
resolves tenants from the `capsules/{tenant}/…` key layout the store already
uses. Customer-held cloud KMS keys (BYOK), rotation campaigns, and the
maker-checker `tenant-keys shred` command are later ADR-0243 slices —
**planned**, not implemented.

## 3. Crypto-shred

`shred()` models single-key deletion: it returns a copy of an envelope with its
wrapped DEK removed, so the ciphertext stays intact and still verifies via
`content_sha256` but can no longer be decrypted. It is **not** called by the
retention scheduler (ADR-0134), and — see the limitation below — it cannot erase
an object that is already stored.

A shredded envelope is permanently unrecoverable; reads raise the named
`ShreddedBlobError` rather than returning garbage. Shredding is idempotent.
Today `shred()` is a Python API
(`novafabric.trust.envelope_encryption.shred`); there is no CLI command for
it yet.

> **Limitation — `shred()` cannot reach a stored object.** `shred()` returns
> a *new* envelope without the wrapped DEK. An envelope already written
> inside its WORM retention window is immutable — its wrapped DEK is part of
> those locked bytes — so `shred()` cannot remove it, and NovaFabric does
> **not** provide per-object erasure of stored capsules. Erasing stored data
> today means destroying the KEK that wrapped it: with per-tenant KEKs
> (below §2), deleting `<tenant>.kek` makes every object of that tenant
> unreadable (fail closed, named error); with a single shared KEK it erases
> the whole store. Plan erasure granularity before choosing a KEK layout.

## 4. Honest limits — read before production

- **Corrected 2026-07-30 — cloud-KMS wrap paths are implemented, not
  planned.** Earlier drafts of this doc said the AWS KMS / Azure Key Vault /
  GCP KMS wrap paths were "infra-gated and not implemented" — that was stale.
  `AwsKmsWrappingBackend`, `AzureKvWrappingBackend`, and `GcpKmsWrappingBackend`
  (`src/novafabric/trust/novaseal/signing_backend.py`) all implement the
  `KeyWrappingBackend` capability this module calls, gated behind the
  `novafabric[seal-aws]` / `novafabric[seal-azure]` / `novafabric[seal-gcp]`
  extras. What works today, in order of maturity: the local 256-bit KEK file
  (experimental; the backend itself is documented as dev/test parity — a
  cloud-KMS-held KEK is the production path), the test-only `MockKmsBackend`, and the three cloud
  wrapping backends (unit-tested against in-memory fakes of each SDK — 31
  passing tests across `tests/seal/test_aws_kms_wrapping.py`,
  `test_cloud_kms_backends.py`, `test_cloud_kms_wrapping.py`). Handing a
  non-wrap-capable backend to the crypto layer still raises
  `NotImplementedError` — it never silently downgrades.
- **Verified against mock/local KMS and in-memory cloud-SDK fakes.** The test
  suite proves the scheme (round-trip, tamper detection, shred,
  WORM-conformance-over-ciphertext, mixed-store reads) against the local-file
  KEK, the mock backend, and the three cloud wrapping backends' fake
  transports. **What has not been done**: end-to-end verification against a
  *live* AWS KMS / Azure Key Vault / GCP KMS endpoint with real credentials —
  that remains the one outstanding gap before recommending cloud KMS in
  production.
- **The §2 env-var wiring is local-KEK-only, by design, today.**
  `backend_router.make_adapter()` — the function §2's `NOVA_OBJECT_STORE_*`
  env vars drive — only ever constructs a `LocalSigningBackend` from
  `NOVA_OBJECT_STORE_KEK_PATH`; there is no env var that selects one of the
  three cloud wrapping backends for the *object-store* encryption feature.
  Using a cloud KMS to wrap the object-store DEK today means constructing
  `EncryptingAdapter(adapter, AwsKmsWrappingBackend(...))` (or the Azure/GCP
  equivalent) yourself in Python, bypassing `make_adapter`'s env-var
  convenience path — this is a real gap in the operator-facing surface, not
  a doc-only omission.
- **Security-Architect review pending.** Per the ADR's acceptance record,
  this feature requires a Security Architect sign-off before production use
  in regulated deployments. That review has not happened yet.
- **Scope is the object capsule store.** The SQLite/Postgres metadata store,
  the local `~/.novafabric/capsules/` filesystem store, and lineage data are
  not covered by this feature — use disk/database encryption there.
- **Confidentiality, not access control.** Anyone with the KEK file and store
  access can decrypt. Pair with the server's RBAC/tenancy controls.

## 5. Operational checklist

- [ ] KEK file generated from a real CSPRNG, mode `0600`, owned by the
      service user.
- [ ] KEK included in the backup/DR inventory ([backup-restore](https://novafabric.ai/docs/ops/backup-restore/));
      restore drill performed with an encrypted object.
- [ ] Both env vars set in the service unit / container spec of every process
      that writes to or reads from the object store. ⚠ A reader process
      *without* the env vars gets the bare adapter and silently returns the
      stored envelope JSON (ciphertext) as the object's bytes — no error. A
      truthy flag without a KEK path refuses to start; a wrong KEK raises
      `DekUnwrapError`.
- [ ] `NOVA_OBJECT_STORE_ALLOW_PLAINTEXT_READS` unset in steady state (set
      only for a documented migration window, see §2.1).
- [ ] Documented owner for the KEK lifecycle. There is **no KEK rotation
      support**: the env wiring holds exactly one KEK, so replacing the file
      makes every earlier envelope unreadable (`DekUnwrapError`). Rotating
      means re-ingesting every object under the new KEK.

---

## See also

- [NovaSeal key management](https://novafabric.ai/docs/novaseal-key-management/) — the operator
  key-management guide this feature extends
- [Backup & restore runbook](https://novafabric.ai/docs/ops/backup-restore/) — where the KEK availability
  trade lands
- [Data residency](https://novafabric.ai/docs/ops/data-residency/) — routing each tenant's capsules to the
  storage backend for its jurisdiction (experimental)
- [ADR-0185](https://novafabric.ai/docs/decisions/) —
  the full decision record, including the encrypt-before-WORM invariant and
  the infra-gated cloud-KMS plan
