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):
- Every
putgenerates a fresh random 256-bit DEK (data-encryption key) and encrypts the payload with AES-256-GCM (96-bit random nonce), using the existingcryptographydependency — no new dependency. Two encryptions of identical plaintext always produce distinct ciphertexts and distinct wrapped DEKs. - The DEK is wrapped by a KEK (key-encryption key) held by the
operator's key backend, via the additive
KeyWrappingBackendcapability (wrap_key/unwrap_key/kek_ref). The wrapped DEK travels inside the stored envelope; the KEK never does. - 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_sha256inside the envelope is likewise computed over the ciphertext — so integrity verification (hashes, Merkle leaves, WORM conformance) never requires decryption or KMS access. - 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 withBlobAuthenticationErrorinstead 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 (noenvelope_versionfield, "v1") still decrypt; each such read logs a warning, because a v1 envelope is not bound and could have been moved. - 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. - 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.binWith 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 readsThe 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 pinEach 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=1Refuses 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-keksTenants 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 — soshred()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>.kekmakes 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, andGcpKmsWrappingBackend(src/novafabric/trust/novaseal/signing_backend.py) all implement theKeyWrappingBackendcapability this module calls, gated behind thenovafabric[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-onlyMockKmsBackend, and the three cloud wrapping backends (unit-tested against in-memory fakes of each SDK — 31 passing tests acrosstests/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 raisesNotImplementedError— 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'sNOVA_OBJECT_STORE_*env vars drive — only ever constructs aLocalSigningBackendfromNOVA_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 constructingEncryptingAdapter(adapter, AwsKmsWrappingBackend(...))(or the Azure/GCP equivalent) yourself in Python, bypassingmake_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); 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_READSunset 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 — the operator key-management guide this feature extends
- Backup & restore runbook — where the KEK availability trade lands
- Data residency — routing each tenant's capsules to the storage backend for its jurisdiction (experimental)
- ADR-0185 — the full decision record, including the encrypt-before-WORM invariant and the infra-gated cloud-KMS plan