Field-Level Envelope Encryption for Entity PII at Rest
This guide is part of the Security & Data Boundaries for Annual Filing area within the Core Architecture & Regulatory Mapping framework: it drills into how the encrypted PII store at the end of the boundary pipeline actually protects an officer’s Social Security number or home address — not with one database-wide key, but with a distinct data key per field, each wrapped by a key-management-service master key, so a single leaked ciphertext exposes nothing on its own.
Scope of This Page
This page covers field-level envelope encryption: generating a fresh data encryption key (DEK) per sensitive field, wrapping that DEK with a key encryption key (KEK) held in a KMS, storing the ciphertext alongside the wrapped key, and rotating the KEK without re-encrypting every field. It covers selective decryption through an access-audited boundary so that reading one officer’s SSN emits an audit event naming the caller and purpose. It excludes the surrounding schema partitioning and row-level security — the table structure that keeps these columns unreadable by the routing service is covered in Building a Secure Entity Registry Database Schema — and it excludes transport encryption, which is a separate boundary.
The Constraint That Forces Envelope Encryption
Corporate annual filings carry exactly the identifiers that regulation treats as most sensitive: officer and beneficial-owner SSNs, entity EINs, and officer home addresses. FinCEN’s beneficial-ownership rule under 31 CFR § 1010.380 requires this information be stored with elevated protection and strict access limitation, held apart from routine public filing metadata; where the data describes a California resident, the CCPA (Cal. Civ. Code § 1798.100 et seq.) adds column-level retention, access, and deletion obligations. A single database-wide encryption key fails these requirements in two ways: it makes “who decrypted this officer’s SSN, and why” unanswerable because every read uses the same key, and it makes key rotation a full-table re-encryption that teams defer indefinitely. Envelope encryption resolves both. Each field gets its own DEK, so decryption is per-field and therefore auditable per-field; the DEK is wrapped by a KEK that never leaves the KMS, so rotating the master key means re-wrapping small keys, not re-encrypting gigabytes of ciphertext. The plaintext DEK exists only transiently in application memory during a single operation and is never persisted.
Prerequisites
- Python 3.10+ and the
cryptographylibrary (42.x) — for AES-256-GCM authenticated encryption viaAESGCM. - A key-management service (AWS KMS, GCP KMS, or HashiCorp Vault) holding the KEK; the KEK’s plaintext never leaves it. This module models the KMS behind a narrow interface so the envelope logic is provider-agnostic.
- A store for the ciphertext, the wrapped DEK, the nonce, and the
kek_idused to wrap — every value needed to decrypt except the KEK plaintext itself. - Structured JSON logging to an append-only audit sink, so every decrypt is a queryable event with caller and purpose.
- Least-privilege service accounts so the routing stage cannot invoke the KMS
unwrapoperation at all.
Implementation: A Field-Level Envelope Cipher
The module below encrypts one field at a time: it asks the KMS for a fresh DEK, uses the plaintext DEK to AES-256-GCM-encrypt the field, stores the ciphertext next to the KMS-wrapped DEK, and discards the plaintext DEK. Decryption reverses the path through an audited gate; rotation re-wraps the DEK under a new KEK without touching the ciphertext. The diagram traces both directions of the envelope.
from __future__ import annotations
import json
import logging
import os
from dataclasses import dataclass
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
# Structured JSON logging to an append-only audit sink — every decrypt is an event.
logging.basicConfig(level=logging.INFO, format="%(message)s")
logger = logging.getLogger("security.envelope")
class UnauthorizedDecryptError(PermissionError):
"""Raised when a caller lacks authorization to unwrap a field's data key."""
class KeyManagementService:
"""A minimal stand-in for AWS KMS / GCP KMS / Vault.
In production these methods are network calls; the KEK plaintext NEVER leaves
the service. Here we simulate wrap/unwrap with a KEK held only inside this object
so the envelope logic around it is exactly what a real provider would drive.
"""
def __init__(self) -> None:
# A rotatable set of KEKs keyed by id. The "active" id is what new wraps use.
self._keks: dict[str, bytes] = {"kek-2026-01": AESGCM.generate_key(bit_length=256)}
self._active = "kek-2026-01"
@property
def active_kek_id(self) -> str:
return self._active
def generate_dek(self) -> tuple[bytes, bytes, str]:
"""Return (plaintext_dek, wrapped_dek, kek_id). The plaintext is used once
and discarded by the caller; only the wrapped form is ever persisted."""
plaintext_dek = AESGCM.generate_key(bit_length=256)
wrapped, kek_id = self._wrap(plaintext_dek)
return plaintext_dek, wrapped, kek_id
def _wrap(self, dek: bytes) -> tuple[bytes, str]:
kek = self._keks[self._active]
nonce = os.urandom(12)
blob = nonce + AESGCM(kek).encrypt(nonce, dek, b"dek-wrap")
return blob, self._active
def unwrap(self, wrapped_dek: bytes, kek_id: str, *, caller: str, purpose: str) -> bytes:
"""Unwrap a DEK behind the audited boundary. Every call is logged with the
caller identity and stated purpose — this is the AU control point."""
if not caller or not purpose:
# Fail closed: an unattributed decrypt request is never authorized.
logger.error(json.dumps({"event": "unwrap_denied", "reason": "unattributed"}))
raise UnauthorizedDecryptError("caller and purpose are required to unwrap")
kek = self._keks.get(kek_id)
if kek is None:
raise KeyError(f"unknown kek_id: {kek_id!r}")
nonce, ct = wrapped_dek[:12], wrapped_dek[12:]
dek = AESGCM(kek).decrypt(nonce, ct, b"dek-wrap")
logger.info(json.dumps({
"event": "dek_unwrapped", "kek_id": kek_id,
"caller": caller, "purpose": purpose, # who read this field and why
}))
return dek
def rewrap(self, wrapped_dek: bytes, kek_id: str) -> tuple[bytes, str]:
"""Rotate: unwrap under the old KEK and re-wrap under the active KEK.
The field ciphertext is never touched — only the small wrapped key changes."""
dek = self.unwrap(wrapped_dek, kek_id, caller="key-rotator", purpose="kek_rotation")
return self._wrap(dek)
def rotate_kek(self) -> str:
new_id = f"kek-{len(self._keks) + 1:04d}"
self._keks[new_id] = AESGCM.generate_key(bit_length=256)
self._active = new_id
logger.info(json.dumps({"event": "kek_rotated", "new_active": new_id}))
return new_id
@dataclass(frozen=True, slots=True)
class EncryptedField:
"""Everything needed to decrypt except the KEK plaintext, which stays in the KMS."""
ciphertext: bytes
nonce: bytes
wrapped_dek: bytes
kek_id: str
field_name: str # bound as AAD so a ciphertext cannot be swapped between columns
class FieldEnvelope:
"""Field-level envelope encryption over a KMS."""
def __init__(self, kms: KeyManagementService) -> None:
self._kms = kms
def encrypt(self, plaintext: str, field_name: str) -> EncryptedField:
dek, wrapped, kek_id = self._kms.generate_dek()
try:
nonce = os.urandom(12)
# Bind the field name as associated data: authenticates which column this
# ciphertext belongs to, so an SSN blob can't be pasted into an EIN column.
ct = AESGCM(dek).encrypt(nonce, plaintext.encode("utf-8"), field_name.encode())
finally:
del dek # drop the plaintext DEK from memory as soon as the field is sealed
return EncryptedField(ct, nonce, wrapped, kek_id, field_name)
def decrypt(self, field: EncryptedField, *, caller: str, purpose: str) -> str:
dek = self._kms.unwrap(field.wrapped_dek, field.kek_id, caller=caller, purpose=purpose)
try:
pt = AESGCM(dek).decrypt(field.nonce, field.ciphertext, field.field_name.encode())
finally:
del dek
return pt.decode("utf-8")
def rotate_field(self, field: EncryptedField) -> EncryptedField:
"""Re-wrap this field's DEK under the active KEK; ciphertext is unchanged."""
new_wrapped, new_kek_id = self._kms.rewrap(field.wrapped_dek, field.kek_id)
return EncryptedField(field.ciphertext, field.nonce, new_wrapped,
new_kek_id, field.field_name)
if __name__ == "__main__":
kms = KeyManagementService()
envelope = FieldEnvelope(kms)
sealed = envelope.encrypt("123-45-6789", field_name="officer_ssn")
logger.info(json.dumps({"event": "field_sealed", "kek_id": sealed.kek_id}))
# Authorized read: emits an audit event naming the caller and purpose.
value = envelope.decrypt(sealed, caller="filing-worker-7", purpose="de_annual_report")
logger.info(json.dumps({"event": "field_read", "recovered_len": len(value)}))
# Rotate the master key, then re-wrap the field's DEK — no re-encryption of ciphertext.
kms.rotate_kek()
rotated = envelope.rotate_field(sealed)
assert rotated.ciphertext == sealed.ciphertext # ciphertext untouched by rotation
logger.info(json.dumps({"event": "field_rotated", "kek_id": rotated.kek_id}))
The envelope keeps the plaintext DEK alive for exactly one operation and drops it in a finally block, so a heap snapshot taken between fields yields no usable key. Binding the field name as GCM associated data means a ciphertext authenticated as officer_ssn will fail to decrypt if it is ever presented in an entity_ein column — the blob cannot be relocated to launder its meaning. Rotation is the payoff of the two-tier design: rotate_field re-wraps a 32-byte key while the field ciphertext, which may be far larger in aggregate across a portfolio, is never rewritten.
Configuration Reference
| Element | Choice | Justification |
|---|---|---|
| Field cipher | AES-256-GCM | Authenticated encryption; the GCM tag detects tampering and the field name binds as AAD (FinCEN 31 CFR § 1010.380 elevated protection). |
| DEK scope | One fresh key per field, per record | Per-field keys make decryption auditable per-field and cap the blast radius of any single key compromise. |
| KEK location | KMS only; plaintext never exported | The master key cannot leak through the application layer or a heap dump; only wrap/unwrap cross the boundary. |
| Nonce | 12 random bytes per encryption | GCM requires a unique nonce per key; a fresh DEK plus a fresh nonce makes reuse impossible by construction. |
| AAD | The field name | Authenticates the column a ciphertext belongs to, so a blob cannot be moved between fields (CCPA Cal. Civ. Code § 1798.100 column obligations). |
| Rotation unit | Re-wrap the DEK, not the ciphertext | Rotating the KEK re-wraps small keys; large field ciphertext is never re-encrypted, so rotation is cheap enough to run on schedule. |
| Unwrap authorization | Requires caller and purpose; fails closed |
An unattributed decrypt is denied and logged; every authorized read is an AU audit event naming who and why. |
Failure Modes and Fallback Routing
Each fault maps onto the parent area’s four boundary classes — SCHEMA_VIOLATION, JURISDICTIONAL_BOUNDARY, TRANSPORT_CIRCUIT_BREAKER, STATUTORY_DEADLINE — plus the access-control controls the boundary model already defines.
- Unattributed decrypt request (access-control, fail-closed). A caller invokes
decryptwithout acallerorpurpose.unwrapdenies the request, logsunwrap_denied, and raisesUnauthorizedDecryptErrorrather than returning plaintext, because an un-auditable read of an officer SSN is itself a boundary violation. This is the same fail-closed posture the boundary model applies to an unassertable jurisdiction. - KMS unavailable during a filing (
TRANSPORT_CIRCUIT_BREAKER). The provider is unreachable, so no DEK can be wrapped or unwrapped. Encryption and decryption both fail closed — the pipeline never falls back to storing plaintext or to a local key — and the filing routes to the manual-intervention queue exactly as a portal outage would, since a missed decrypt is recoverable but a leaked key is not. - Ciphertext presented in the wrong column (
SCHEMA_VIOLATION). A blob authenticated as one field is loaded into another; the GCM tag verification fails on decrypt and raises before any plaintext is produced. The record is quarantined for a data-steward, because a field-swap is either corruption or an attempt to launder PII across the partition the Building a Secure Entity Registry Database Schema layer enforces. - Rotation interrupted mid-portfolio (recoverable). A KEK rotation re-wraps fields one at a time and the process dies halfway, leaving some fields wrapped under the old
kek_idand some under the new one. Because eachEncryptedFieldrecords its ownkek_id, decryption still works for both sets and rotation is simply resumable from where it stopped — no field is stranded, and the retained old KEK stays in the KMS until the last field referencing it has been re-wrapped. The obligations these fields belong to keep flowing through the Compliance Metadata Schemas layer throughout.
Frequently Asked Questions
Why a data key per field instead of one key for the whole table?
Two reasons, both regulatory as much as cryptographic. A per-field DEK makes every decryption a discrete, attributable event, so “who read this officer’s SSN and why” has a concrete answer — a single table key makes that question unanswerable because every read looks identical. And a per-field key caps blast radius: compromising one DEK exposes one field, not the entire PII store. The cost is one extra wrapped key stored per field, which is negligible next to the audit and containment it buys under 31 CFR § 1010.380.
How does rotation avoid re-encrypting everything?
Because the KEK never encrypts the field directly — it only wraps the small DEK. Rotating the master key means unwrapping each 32-byte DEK under the old KEK and re-wrapping it under the new one; the field ciphertext, which the DEK encrypted, is never touched. That is the entire point of the two-tier envelope: rotation moves kilobytes of keys instead of gigabytes of ciphertext, so it is cheap enough to run on a schedule rather than deferred until an incident forces it.
What stops a leaked ciphertext from being decrypted on its own?
The ciphertext is useless without its DEK, and the DEK is stored only in wrapped form. Unwrapping the DEK requires the KEK, which never leaves the KMS, so an attacker holding a database dump has the ciphertext and an encrypted key they cannot open. They would additionally have to compromise the KMS and pass its access-controlled unwrap boundary — and every such unwrap is logged with the caller and purpose, so even a successful one leaves evidence.
Why bind the field name as associated data?
GCM associated data is authenticated but not encrypted, so binding the field name ties a ciphertext to the column it legitimately belongs to. If a blob authenticated as officer_ssn is presented for decryption as entity_ein, the tag check fails and no plaintext is produced. This blocks a field-swap attack where an adversary relocates an encrypted value to launder its meaning or bypass the column-level partition the secure registry schema enforces.