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).

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) 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 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:

# 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:

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:

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())
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)

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):

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

5. Operational checklist


See also