Core Architecture Regulatory Mapping

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 cryptography library (42.x) — for AES-256-GCM authenticated encryption via AESGCM.
  • 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_id used 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 unwrap operation 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.

Field-level envelope encryption: the encrypt path wraps a per-field data key under a KMS master key, and the audited decrypt path mirrors it The encrypt path generates a fresh data encryption key from the KMS, uses its plaintext form to AES-256-GCM-encrypt one field, stores the ciphertext beside the wrapped data key and nonce, and discards the plaintext key. The decrypt path unwraps the data key through an access-audited gate that logs caller and purpose, then decrypts. Rotation re-wraps the small data key under a new master key without re-encrypting the ciphertext. Per-field data key wrapped by a KMS master key — a leaked ciphertext alone decrypts nothing ENCRYPT → Plaintext field officer SSN / EIN KMS: generate DEK returns plaintext DEK + wrapped DEK KEK never leaves KMS AES-256-GCM plaintext DEK encrypts field → ciphertext then discard plaintext DEK Storage ciphertext wrapped DEK · nonce kek_id no plaintext key stored ← DECRYPT Storage returns wrapped DEK + ciphertext + nonce + kek_id Access-audited gate KMS unwrap DEK log caller · purpose deny if not authorized AES-256-GCM plaintext DEK decrypts discard DEK after use Recovered field only for authorized use KEK rotation unwrap DEK under old KEK → re-wrap under new KEK ciphertext is never touched — only the small wrapped key changes
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.

  1. Unattributed decrypt request (access-control, fail-closed). A caller invokes decrypt without a caller or purpose. unwrap denies the request, logs unwrap_denied, and raises UnauthorizedDecryptError rather 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.
  2. 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.
  3. 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.
  4. 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_id and some under the new one. Because each EncryptedField records its own kek_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.