# pg_vault_tde Technical Reference **Version**: 1.7 **PostgreSQL**: 17.x, 18.x (19.x planned) **License**: BSD (PostgreSQL License) **Copyright**: Β© 2026 Miriade S.r.l. --- ## Table of Contents 1. [Architecture](#architecture) 2. [Table Access Method (TAM)](#table-access-method) 3. [Crypto Layer](#crypto-layer) 4. [Performance](#performance) 5. [Index Access Method (IAM)](#index-access-method-iam) 6. [Logical Decoding and Replication](#logical-decoding-and-replication) 7. [PKCS#11 / HSM Provider](#pkcs11--hsm-provider) 8. [Known Limitations](#known-limitations) 9. [Security Considerations](#security-considerations) 10. [Wire Format Reference](#wire-format-reference) 11. [SQL API Reference](#sql-api-reference) 12. [Extension Initialization](#extension-initialization) 13. [Testing Strategy](#testing-strategy) 14. [Packaging](#packaging) 15. [Roadmap](#roadmap) 16. [Contributing](#contributing) --- ## Architecture pg_vault_tde is a PostgreSQL extension that provides Transparent Data Encryption at the Table Access Method layer. It operates entirely within the extension API; zero modifications to PostgreSQL core are required. ### PostgreSQL Version Compatibility | PG Major | Status | Notes | |----------|--------|-------| | 17 | βœ… Supported | Baseline API set | | 18 | βœ… Supported | `scan_bitmap_next_tuple` signature change β€” guarded with `PG_VERSION_NUM` | | 19 | πŸ”œ Planned | Infrastructure ready; audit at release | This is the canonical PostgreSQL support statement for the extension; it says nothing about operating systems or about which combinations are actually exercised in CI β€” see [Support Matrix](#support-matrix) under Packaging for that. ### Version-Specific API Differences | API / Struct | PG 17 | PG 18 | Guard Macro | |-------------|-------|-------|-------------| | `scan_bitmap_next_tuple` | `(scan, slot, recheck)` | `(scan, slot, recheck, lossy, exact)` | `PG_VERSION_NUM >= 180000` | | `shmem_request_hook` | Available | Available | *(none needed)* | | `GetHeapamTableAmRoutine()` | Returns `const *` | Returns `const *` | *(none needed)* | | `tuplesort_begin_index_btree()` assert on `relam` | Asserts `rd_rel->relam == BTREE_AM_OID` | No such assert | `PG_VERSION_NUM < 180000` β€” `pg_vault_tde_ambuild()` temporarily impersonates `BTREE_AM_OID` on `index->rd_rel->relam` during the sort | | `BuildSpeculativeIndexInfo()` assert on ON CONFLICT | Asserts against non-btree-looking `relam` for unique `tde_btree` indexes | No such assert | `PG_VERSION_NUM < 180000` β€” `tde_executor_start_hook()` swaps `relam` back to `BTREE_AM_OID` for the duration of the query, restored via a `MemoryContextCallback` | ### Design Goals | Goal | Achieved | Notes | |---|---|---| | Plug-and-play | βœ… | `shared_preload_libraries` + `CREATE EXTENSION` only | | Zero core patches | βœ… | Pure extension API (`tableam`, `indexam`) | | AES-256-GCM per-tuple | βœ… | Authenticated encryption; integrity verified on read | | Hardware acceleration | βœ… | OpenSSL 3.x EVP dispatch β†’ AES-NI / ARM Crypto | | Key rotation | βœ… | Lazy generation-epoch detection; no scan needed | | MVCC compatibility | βœ… | HeapTupleHeader stays plaintext | | pg_dump / pg_restore | βœ… | Decrypts transparently at TAM scan layer | | Page checksums | βœ… | Checksums cover encrypted bytes | ### Component Map ``` PostgreSQL Core └── Extension API β”œβ”€β”€ TAM: encrypted_heap src/tam/pg_vault_tde_tam.c β”‚ β”œβ”€ Write path (encrypt) tde_encrypt_heap_tuple() β”‚ └─ Read paths (decrypt) pg_vault_tde_decode_slot() β”œβ”€β”€ IAM: tde_btree src/iam/pg_vault_tde_iam.c β”‚ └─ AES-256-SIV key encrypt tde_iam_encrypt_key() β”œβ”€β”€ Crypto src/crypto/pg_vault_tde_crypto.c β”‚ β”œβ”€ tde_gcm_encrypt() AES-256-GCM via OpenSSL 3.x EVP β”‚ └─ tde_gcm_decrypt() Authenticated decryption β”œβ”€β”€ Per-relation DEK cache src/kms/pg_vault_tde_catalog.c β”‚ β”œβ”€ DEK cache (shmem HTAB) TdeRelDekMap (one shared LWLock) β”‚ └─ get DEK for a relation pg_vault_tde_kms_get_rel_dek(relid) β”œβ”€β”€ KMS provider vtable src/kms/pg_vault_tde_kms.c β”‚ └─ Vault provider (libcurl) vault_provider_{wrap,unwrap,rewrap}_dek() β”œβ”€β”€ Local wallet provider src/kms/pg_vault_tde_kms_local.c (PKCS#12) β”œβ”€β”€ DEK catalog (on-disk) src/kms/pg_vault_tde_catalog.c (wrapped_dek) β”œβ”€β”€ Rotation background worker src/kms/pg_vault_tde_rotation_bgw.c β”œβ”€β”€ HW acceleration src/crypto/pg_vault_tde_hw_accel.c β”œβ”€β”€ Logical decoding plugin src/logical/pg_vault_tde_pgoutput.c β”œβ”€β”€ Backup src/backup/pg_vault_tde_backup.c β”‚ (+ pg_dump_tde.c / pg_restore_tde.c) └── Entry point src/pg_vault_tde.c (_PG_init) ``` ### Key Lifecycle How each provider is *configured* β€” GUCs, credentials, per-database settings β€” is covered in [Configure Key Access](../README.md#3-configure-key-access) for Vault / OpenBao, the local wallet and PKCS#11. This chapter describes what happens to key material once a provider is in place; the PKCS#11 provider gets its own chapter below because it is the only one with meaningful implementation surface of its own. ``` Vault / OpenBao (KEK owner) ──or── Local wallet (PKCS#12, KEK-on-disk) β”‚ β”‚ unwrap wrapped_dek via active provider vtable (synchronous; β”‚ libcurl HTTP(S) for Vault, AES-256-WRAP for local) β–Ό pg_vault_tde_kms_get_rel_dek(relid) [src/kms/pg_vault_tde_catalog.c] β”‚ fast path: LW_SHARED hash_search of TdeRelDekMap (cache hit) β”‚ slow path: read pg_vault_tde_catalog.wrapped_dek β†’ provider β”‚ unwrap β†’ hash_search(HASH_ENTER) under LW_EXCLUSIVE β–Ό TdeRelDekMap (shmem HTAB) [one entry per (dbid, relid); single β”‚ shared LWLock; generation + prev_dek] β”‚ DEK (32 bytes) copied into a stack buffer on every call; the crypto β”‚ layer caches the AES key schedule keyed by (relid, generation) β€” no β”‚ dbid there, those statics are per-backend and a backend is bound to β”‚ one database β–Ό tde_gcm_encrypt() / tde_gcm_decrypt() [src/crypto/pg_vault_tde_crypto.c] β”‚ β–Ό Disk: [HeapTupleHeader | attributes, value bytes encrypted | IV(12) | GCM-TAG(16) | VER(1) | GEN(8)] ``` ### Shared Memory Layout Since v1.7 the cache is a shared-memory **hash table** (`HTAB`), not a fixed array scanned linearly. Each entry is one `TdeRelDekMap`, keyed by `(dbid, relid)`: ```c /* * Cache key. relid is unique only WITHIN a database β€” never across * databases, never cluster-wide β€” while this HTAB is one segment read by the * backends of every database. CREATE DATABASE physically copies the * template's directory, so a clone hands out pg_class OIDs identical to its * template's: colliding relids are normal, not a corner case. */ typedef struct TdeRelDekMapKey { Oid dbid; /* always MyDatabaseId */ Oid relid; /* effective relid (TOAST β†’ parent, etc.) */ } TdeRelDekMapKey; /* Per-relation DEK entry β€” value type of the TdeRelDekMap HTAB (v1.5+) */ typedef struct TdeRelDekMap { TdeRelDekMapKey key; /* hash key */ char dek[TDE_DEK_LEN]; /* current AES-256 DEK, 32 bytes */ char prev_dek[TDE_DEK_LEN]; /* previous DEK (valid during rotation) */ uint64 generation; /* rotation epoch for this relation */ bool dek_valid; /* true iff dek[] holds a live key */ bool prev_dek_valid; /* true iff prev_dek[] is populated */ } TdeRelDekMap; ``` - `dbid` is **not** a parameter of any public function in `pg_vault_tde_catalog.h`. `tde_rel_dek_key()` fills it from `MyDatabaseId` when it builds the key, because every path that reaches the cache runs connected to the database owning the relation: a regular backend, the rotation BGW after `BackgroundWorkerInitializeConnectionByOid()`, or a walsender during logical decoding. One derivation point instead of fourteen call sites that could each pass the wrong value. - The **on-disk** catalog needs no dbid. `pg_vault_tde_catalog` is an ordinary table created by `CREATE EXTENSION` in the extension's schema, so it exists once per database and its `relid` primary key is unambiguous there; every access (`tde_catalog_read_row`, `rewrap_all`, `read_all_wrapped`, `upsert_row`) goes through `table_open()` in the current backend's database. The local wallet is per-database for the same reason, at `/var/lib/pg_vault_tde//wallet.p12`. Shared memory was the only place where per-database namespaces met. - A relid-only key does **not** silently return wrong plaintext: the GCM AAD binds `MyDatabaseId` (`tde_compute_aad()`), so the victim database gets `AES-256-GCM authentication FAILED` on intact data. A read outage, not a corruption β€” covered by `tap/21_cache_key_cross_db.t`. - `TDE_DEK_LEN` is defined **only** in `src/include/pg_vault_tde_kms.h`. - The HTAB lives in `src/kms/pg_vault_tde_catalog.c`, created with `ShmemInitHash("pg_vault_tde_rel_dek_map", capacity, capacity, &info, HASH_ELEM | HASH_BLOBS)` where `capacity = pg_vault_tde.max_encrypted_relations`. The segment is sized with `hash_estimate_size(capacity, sizeof(TdeRelDekMap))`. `HASH_BLOBS` means the key is hashed as raw bytes, so `tde_rel_dek_key()` zeroes the struct before filling it β€” padding must not leak into the hash. Capacity is cluster-wide: with encrypted tables in several databases, budget for the sum. - There is **no per-entry lock**. A single `LWLock` (file-scope `rel_dek_lock`) from a **named** tranche guards the whole table: `RequestNamedLWLockTranche("pg_vault_tde_rel_dek_map", 1)` in the `shmem_request_hook`, then `&GetNamedLWLockTranche("pg_vault_tde_rel_dek_map")[0].lock` in the `shmem_startup_hook`. The lock is taken `LW_SHARED` for lookups and `LW_EXCLUSIVE` for insert/evict/rotate. - A second, fixed-size shmem struct (`pg_vault_tde_kms_cache`, in `pg_vault_tde_kms.c`) holds the shared Vault token. Its lock uses a **dynamic** tranche (`LWLockNewTrancheId()`), which is why that call lives in the `shmem_startup_hook` and not `_PG_init` β€” see [Extension Initialization](#extension-initialization). --- ## Table Access Method ### Design Pattern: Mutable Copy of heapam ```c static TableAmRoutine tde_methods; /* zero-initialised at load time */ void pg_vault_tde_tam_init(void) { memcpy(&tde_methods, GetHeapamTableAmRoutine(), sizeof(TableAmRoutine)); /* save originals, then install TDE wrappers */ } ``` All structural operations (VACUUM, CLUSTER, index build, truncate, scan state management) delegate to heapam unchanged. Only the five write paths, eight read paths, two visibility/build paths, and two rewrite path are overridden. ### Overridden Callbacks #### Write Paths (encrypt before storing) | Callback | Purpose | |---|---| | `tuple_insert` | Single-row INSERT | | `tuple_insert_speculative` | Speculative INSERT (ON CONFLICT) | | `multi_insert` | COPY FROM / bulk INSERT | | `tuple_update` | UPDATE | | `tuple_delete` | DELETE | All write paths follow the same pattern: 1. Materialize the slot into a `HeapTuple` (plaintext) 2. Call `tde_encrypt_heap_tuple()` β†’ returns palloc'd encrypted `HeapTuple` 3. Call the heapam storage function (`heap_insert`, `heap_update`) 4. Copy the physical TID back to the slot 5. `OPENSSL_cleanse` + `pfree` the plaintext copy #### Read Paths (decrypt after fetching) All read paths that cause heapam to fill a `TupleTableSlot` with a buffer-backed `HeapTuple` MUST call `pg_vault_tde_decode_slot()`. | Callback | Scan Type | Status | |---|---|---| | `scan_getnextslot` | SeqScan | βœ… Override | | `scan_getnextslot_tidrange` | TidRangeScan | βœ… Override | | `index_fetch_tuple` | Index Scan, Index Only Scan | βœ… Override | | `scan_bitmap_next_tuple` | BitmapHeapScan | βœ… Override | | `scan_analyze_next_tuple` | ANALYZE | βœ… Override | | `scan_sample_next_tuple` | TABLESAMPLE | βœ… Override | | `tuple_fetch_row_version` | TidScan, UPDATE recheck | βœ… Override | | `tuple_lock` | SELECT FOR UPDATE/SHARE | βœ… Override | #### Visibility & Index-Build Paths | Callback | Purpose | |---|---| | `tuple_satisfies_snapshot` | Visibility recheck for RI foreign-key trigger (`RI_FKey_check`) β€” heapam's version asserts a live buffer pin, which our decrypt-into-palloc'd-tuple path doesn't hold | | `index_build_range_scan` | CREATE INDEX / REINDEX β€” decrypts each tuple before `FormIndexDatum` extracts key values, otherwise indexes would be built over ciphertext| #### Rewrite Paths (decrypt β†’ process β†’ re-encrypt) | Callback | Trigger | Notes | |---|---|---| | `relation_copy_for_cluster` | `VACUUM FULL`, `CLUSTER` | Reads each tuple via `heap_getnext` (with `rd_tableam` impersonation), decrypts, re-encrypts into the new heap via `rewrite_heap_tuple`. Clears `HEAP_HASEXTERNAL` on the encrypted copy before writing; `tde_tuple_has_external_slow` (per-attribute varlena scan) is used on subsequent DELETE to locate TOAST chunks regardless of the infomask flag. | | `relation_toast_am` | TOAST table creation | Always selects `encrypted_heap` as the TOAST AM (since 1.7.2 regardless of `pg_vault_tde.toast_encryption`, PSQLE-223), so the encrypted chunks are decrypted through the same `index_fetch_tuple`/`scan_getnextslot` hooks as the main table. | ### pg_vault_tde_decode_slot This function is the core of the read path. It: 1. Casts the slot to `BufferHeapTupleTableSlot` (known buffer-backed after heapam) 2. **Guards against double-decode**: checks `bslot->buffer == InvalidBuffer` 3. Saves `bslot->base.tuple->t_self` (physical TID) and `t_tableOid` (relation OID) from the buffer page 4. Calls `tde_decrypt_heap_tuple(bslot->base.tuple, saved_tableoid)` β€” decrypts the buffer-backed tuple directly (verifies GCM tag via OpenSSL). **No `heap_copytuple` and no explicit `ExecClearTuple`**: the buffer pin must stay held until the force-store below. Releasing it early forces O(rows) buffer re-pins during a sequential scan instead of O(pages) (see performance note in the function header). 5. Stamps `plain->t_self = saved_tid`, `plain->t_tableOid = saved_tableoid` 6. Calls `ExecForceStoreHeapTuple(plain, slot, true)` β€” this performs the single internal `ExecClearTuple` that releases the buffer pin (the only release point) 7. **Manually sets `slot->tts_tid = saved_tid`** β€” `ExecForceStoreHeapTuple` does NOT restore `tts_tid` for buffer slots; it must be set explicitly ```c static void pg_vault_tde_decode_slot(TupleTableSlot *slot) { BufferHeapTupleTableSlot *bslot = (BufferHeapTupleTableSlot *) slot; HeapTuple plain; ItemPointerData saved_tid; Oid saved_tableoid; /* * Guard against double-decode: after ExecForceStoreHeapTuple the buffer * is released (buffer == InvalidBuffer) but base.tuple is still set. * Re-entering here would try to decrypt already-plain data. */ if (bslot->buffer == InvalidBuffer) return; /* * Read TID + relation OID from the buffer-backed pointer directly, NOT from * ExecFetchSlotHeapTuple(slot, false, ...) which returns the tupdata * workspace with an uninitialized t_self. */ ItemPointerCopy(&bslot->base.tuple->t_self, &saved_tid); saved_tableoid = bslot->base.tuple->t_tableOid; /* * Decrypt the buffer-backed tuple in place (verifies GCM tag; ereport(ERROR) * on tamper). Do NOT call heap_copytuple/ExecClearTuple first: the pin must * stay held until ExecForceStoreHeapTuple, which releases it exactly once per * tuple. Releasing early causes O(rows) buffer hits on sequential scans. */ plain = tde_decrypt_heap_tuple(bslot->base.tuple, saved_tableoid); ItemPointerCopy(&saved_tid, &plain->t_self); plain->t_tableOid = saved_tableoid; ExecForceStoreHeapTuple(plain, slot, true); /* internal ExecClearTuple releases pin */ ItemPointerCopy(&saved_tid, &slot->tts_tid); /* ExecForceStoreHeapTuple does not set this */ } ``` ### rd_tableam Identity-Check Workaround Several heapam internal functions protect themselves with: ```c if (rel->rd_tableam != GetHeapamTableAmRoutine()) ereport(ERROR, "only heap AM is supported"); ``` Since our `&tde_methods` lives at a different address than heapam's static struct, these checks fail when called on our tables. Two callbacks require the workaround: 1. **`index_fetch_tuple`** β€” calls `heap_hot_search_buffer` on every index lookup 2. **`index_build_range_scan`** β€” calls `heap_getnext` internally during `CREATE INDEX` **Fix pattern** (safe β€” `RelationData` is per-backend): ```c const TableAmRoutine **rdam = (const TableAmRoutine **)(void *)&rel->rd_tableam; const TableAmRoutine *saved_am = *rdam; *rdam = GetHeapamTableAmRoutine(); /* impersonate heapam */ result = heapam_original_cb(rel, ...); *rdam = saved_am; /* restore BEFORE any error path */ /* Then decrypt slot contents */ ``` `RelationData` is per-backend (local relcache copy). The swap window is a single function call. No signal/interrupt can preempt between the swap and restore in a single-threaded backend. ### TOAST Table Override ```c static Oid pg_vault_tde_toast_am(Relation rel) { if (!pg_vault_tde_toast_encryption) ereport(WARNING, ... "has no effect" ...); /* PSQLE-223 */ encheap_oid = get_table_am_oid("encrypted_heap", true); if (OidIsValid(encheap_oid)) return encheap_oid; return HEAP_TABLE_AM_OID; /* only if the AM is missing */ } ``` TOAST tables are created with the `encrypted_heap` AM so that every TOAST chunk is encrypted individually using the parent relation's DEK. On PG 18 the `heap_getnext` identity assertion inside the TOAST index build would reject `encrypted_heap`; the `rd_tableam` impersonation workaround is applied during `index_build_range_scan` to satisfy this assertion. `pg_vault_tde.toast_encryption` has had no effect since 1.7.2 (PSQLE-223). The TOAST pipeline encrypts every chunk it writes whatever the TOAST table's AM, so `off` never gave plaintext TOAST: up to 1.7.1 it gave a heap TOAST table of encrypted chunks, read back undecrypted, and every out-of-line value of the table failed. Setting it `off` now only raises a `WARNING`; the parameter is removed in 1.8. A table left unreadable by 1.7.1 is repaired by giving its TOAST table the `encrypted_heap` access method β€” see README.md β€Ί Upgrading to 1.7.2 β€Ί Tables created with `toast_encryption = off`. ### PG18-Specific API Notes #### scan_bitmap_next_tuple The signature changed in PG18: ```c /* PG ≀ 17 */ bool (*scan_bitmap_next_tuple)(TableScanDesc, TBMIterateResult *, TupleTableSlot *); /* PG 18 */ bool (*scan_bitmap_next_tuple)(TableScanDesc, TupleTableSlot *, bool *, uint64 *, uint64 *); ``` Always verify against `src/include/access/tableam.h` before implementing or modifying this callback. --- ## Crypto Layer ### Algorithm - **Encryption**: AES-256-GCM via OpenSSL 3.x `EVP_EncryptInit_ex2` - **IV generation**: `pg_strong_random()` (PostgreSQL's `/dev/urandom` wrapper) β€” NOT `RAND_bytes()` because PostgreSQL processes can fork at any time; OpenSSL PRNG state is not fork-safe in all configurations. - **Hardware acceleration**: OpenSSL 3.x EVP dispatch automatically selects the hardware provider (AES-NI on x86_64; ARM Crypto Extensions on aarch64). - **Authentication**: 128-bit GCM tag appended to every encrypted region. Any bit-flip in ciphertext, IV, or associated data raises an `ERROR` (not a silent wrong result). ### Wire Format per Encrypted Region **Version 5** is the format written today; **version 4** is still read, so a table written before the upgrade keeps working. The legacy v1/v2/v3 formats were **removed**. (The byte `0x02` still appears only in the `pg_dump_tde` *backup block* format β€” a separate code path, see [Backup](../README.md#encrypted-backups).) v5 is **structure preserving**: every attribute stays at its own offset with its own length, and only the bytes of the VALUES are replaced by ciphertext. ``` +--------------------------------+----------+----------+-------+----------+ | ATTRIBUTES, values encrypted | IV | GCM TAG | VER | GEN | | D bytes (= plaintext data len) | 12 bytes | 16 bytes | 1 byte| 8 bytes | +--------------------------------+----------+----------+-------+----------+ random 0x05 uint64 LE ``` Total overhead: `TDE_V4_OVERHEAD = 37` bytes (`TDE_GCM_IV_LEN=12` + `TDE_GCM_TAG_LEN=16` + `1` version byte + `TDE_V4_GEN_LEN=8`), identical to v4 β€” a v5 tuple is exactly as long as the v4 tuple for the same row. v4 replaced the whole user-data region with one opaque blob (`[IV | CIPHERTEXT | TAG | VER | GEN]`) while the tuple header, copied verbatim, still advertised `natts` attributes laid out per the tuple descriptor. Core code that deforms an on-disk tuple then walked ciphertext as if it were a tuple β€” and `heap_update()` does exactly that, reading the indexed attributes straight off the page to decide HOT and which indexes to maintain. Past the first variable-length column the attribute offset is not cached, so the walk read a varlena length header out of ciphertext, got a length of up to 1 GB and left the page: **SIGSEGV** (PSQLE-165). Any index on such a column was enough, `tde_btree` included β€” the trigger is the index attribute bitmap, not the access method. What v5 gives up in exchange: the structural bytes stay in clear, because they are what makes the walk possible. Concretely the **exact byte length of every variable-length column** is visible in the heap file, along with whether the value is compressed or held out of line. Fixed-length columns leak nothing (their length is in the catalog), and the row length and null bitmap were already visible in v4. Attribute values themselves are never in clear β€” regression test 143 reads the raw heap file and asserts it. The AEAD is unchanged: the value bytes are gathered into one buffer, handed to `tde_gcm_encrypt()` and scattered back, so ciphertext, tag and AAD are bit-identical to what v4 produced for the same input. **Upgrading an existing table.** v4 tuples are read transparently, but they keep their old layout: `UPDATE` on a v4 row with an index behind a variable-length column still crashes, because nothing can make that layout walkable after the fact. `VACUUM FULL` (or `CLUSTER`) rewrites every row through the TAM and migrates the table to v5. v4 binds each tuple to its location by passing `[MyDatabaseId(4) | relid(4) | generation(8)]` (little-endian, `TDE_V4_AAD_LEN = 16` bytes) as GCM Additional Authenticated Data β€” zero wire overhead; prevents cross-table ciphertext smuggling. The AAD is reconstructed on decrypt from the **stored** generation in the wire trailer (not the current generation), so old-generation rows still authenticate during the rotation window. When `user_len == 0` (all-NULL tuple, or tuple with only system columns), `tde_gcm_encrypt()` still produces a full `TDE_V4_OVERHEAD`-byte (37) block. This exercises the GCM tag path on zero data; the decrypt path handles it symmetrically. Test 16 covers this edge case. ### Memory Security - DEK copies in per-backend memory are `OPENSSL_cleanse`d before `pfree`. - Plaintext `HeapTuple` intermediates are `OPENSSL_cleanse`d after encryption. - Per-backend EVP contexts are freed via `on_proc_exit()` callbacks (`tde_iam_ctx_cleanup` for AES-SIV; analogous cleanup for GCM contexts). - Shared-memory DEK is `OPENSSL_cleanse`d during rotation before the new key is written. --- ## Performance ### Per-Backend EVP Contexts Keyed by (relid, generation) Two costs hide on the per-tuple crypto path: allocating an `EVP_CIPHER_CTX` (a heap malloc) and installing the AES-256 **key schedule** (`EVP_EncryptInit_ex2` with the DEK). A naive implementation pays both on every tuple. pg_vault_tde caches each context together with the key it is keyed for: ```c typedef struct TdeCipherSlot { EVP_CIPHER_CTX *ctx; Oid relid; uint64 generation; } TdeCipherSlot; static TdeCipherSlot tde_enc = { NULL, InvalidOid, 0 }; /* encrypt direction */ static TdeCipherSlot tde_dec = { NULL, InvalidOid, 0 }; /* decrypt direction */ ``` Each context is allocated once per backend (`EVP_CIPHER_CTX_new()` on first use). The expensive key-schedule install runs **only when the slot's cached `(relid, generation)` differs** from the current operation β€” i.e. on the first tuple of a relation and again after a key rotation. For every other tuple the installed schedule is reused and only the per-tuple IV is rearmed with `EVP_EncryptInit_ex2(ctx, NULL, NULL, iv, NULL)`. Consecutive tuples of the same relation (the common bulk-INSERT / sequential-scan case) therefore skip the key schedule entirely. On decrypt the slot is keyed by the **stored** generation read from the wire trailer, so old-generation rows decrypted via `prev_dek` during the rotation window get their own cached schedule without thrashing the current-generation one. On any fatal OpenSSL error `tde_crypto_ctx_cleanup()` frees and NULL-outs both contexts (and resets their `relid` to `InvalidOid`) so the next call re-allocates and re-keys cleanly. Both contexts are freed in the `on_proc_exit()` callback `tde_crypto_ctx_cleanup()`, which also wipes the IV batch. The same allocate-once pattern is applied to the IAM: a single per-backend AES-256-SIV context, re-keyed only when the `(idx_oid, generation)` pair changes (`tde_iam_ctx_prepare`), freed by `tde_iam_ctx_cleanup()`. ### IV Batch Generation `pg_strong_random()` is a syscall to `/dev/urandom` or `getrandom(2)`. A non-batched implementation would pay one syscall per encrypted tuple. Instead, pg_vault_tde batches 256 IVs per `pg_strong_random()` call: ```c #define TDE_IV_BATCH_SIZE 256 #define TDE_IV_BATCH_BYTES (TDE_IV_BATCH_SIZE * TDE_GCM_IV_LEN) static char iv_batch[TDE_IV_BATCH_BYTES]; static int iv_batch_pos = TDE_IV_BATCH_SIZE; /* start empty */ static int iv_batch_pid = 0; /* the process that filled it */ static void tde_next_iv(unsigned char *iv_out) { if (iv_batch_pos >= TDE_IV_BATCH_SIZE || iv_batch_pid != MyProcPid) { if (!pg_strong_random(iv_batch, TDE_IV_BATCH_BYTES)) ereport(ERROR, (errmsg("[CRYPTO] Failed to generate IV batch"))); iv_batch_pos = 0; iv_batch_pid = MyProcPid; } memcpy(iv_out, iv_batch + iv_batch_pos * TDE_GCM_IV_LEN, TDE_GCM_IV_LEN); iv_batch_pos++; } ``` The buffer is wiped with `OPENSSL_cleanse()` in the backend-exit cleanup. This amortises the syscall cost across 256 tuples. A batch belongs to the process that filled it. A `fork()` copies it, and two processes serving the same IVs under one DEK would void GCM for those tuples. No PostgreSQL process forks after drawing an IV β€” the postmaster encrypts nothing β€” so every process starts empty; the pid check keeps it so if that changes, and an Assert on assertion-enabled builds compares `MyProcPid` with `getpid()` (PSQLE-178). `tap/48_iv_uniqueness.t` reads every IV off the raw pages and checks that none repeats under one DEK generation. **Limit per key.** With random 96-bit IVs, NIST SP 800-38D allows at most 2^32 encryptions under one key β€” here one DEK generation: every tuple written, every row rewritten, every TOAST chunk. `rotate_online()` starts a new generation; the README ("Routine administration") says how to estimate where a table stands. ### Benchmark Run the included benchmark against a live container: ```bash bash bench_tde.sh 100000 ``` The script runs INSERT, SELECT, UPDATE, index scan, and TABLESAMPLE workloads on `plain_heap` vs `encrypted_heap`, and prints a comparison table with overhead percentages. Use `pg_vault_tde.enabled = off` (requires a server restart β€” the GUC is `PGC_POSTMASTER`) to isolate pure TAM overhead (no crypto) from actual encryption cost. See the warning in README.md before toggling this on any database with existing `encrypted_heap` data. ``` β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ Shared memory β”‚ β”‚ ──────────────────────────────────────────────────│ β”‚ LWLock (embedded by value) β”‚ β”‚ HTAB (TdeRelDekMap) β”‚ β”‚ β”œβ”€ relid: Oid (key) β”‚ β”‚ β”œβ”€ dek[32]: char (current DEK) β”‚ β”‚ β”œβ”€ prev_dek[32]: char (rotation window) β”‚ β”‚ β”œβ”€ generation: uint64 β”‚ β”‚ └─ dek_valid / prev_dek_valid: bool β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β–² pg_vault_tde_kms_get_rel_dek(relid) β”‚ fast path: LW_SHARED cache hit β”‚ slow path: catalog read β†’ KMS unwrap β†’ cache insert β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ pg_vault_tde_catalog (on-disk system table) β”‚ β”‚ ──────────────────────────────────────────────────│ β”‚ relid, generation, β”‚ β”‚ wrapped_dek, kms_provider, created_at, updated_at β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ``` 1. Every encrypt/decrypt call fetches the DEK with `pg_vault_tde_kms_get_rel_dek()`: `hash_search(HASH_FIND)` under `LW_SHARED`, `memcpy` into a stack buffer, release. The buffer is `OPENSSL_cleanse`d after use (caller responsibility). 2. On a cache miss (first access after startup, or after the entry was evicted by rotation), the slow path reads `pg_vault_tde_catalog.wrapped_dek`, unwraps it via the active KMS provider, and inserts the entry under `LW_EXCLUSIVE`. 3. Cross-call key-schedule reuse lives in the **crypto layer**, not here: the `TdeCipherSlot` EVP contexts cache the installed AES schedule keyed by `(relid, generation)` (see [Per-Backend EVP Contexts](#per-backend-evp-contexts-keyed-by-relid-generation)), so re-fetching the DEK bytes per call is cheap and the expensive schedule install is amortised. ### Generation-Epoch Rotation Key rotation is **per-relation** via `pg_vault_tde_rotate_online(relname, batch_size)`. The rotation worker runs one transaction: 1. Takes `ShareRowExclusiveLock` on the heap β€” writers and a second rotation wait, `SELECT` continues β€” and only then its snapshot, so rows committed by the writers it waited for are re-encrypted too. 2. `pg_vault_tde_catalog_zero_rel_dek()` moves the current DEK to `prev_dek`, wipes `dek[32]`, sets `dek_valid = false` and `rotating = true`, and keeps the outgoing key in the worker's own memory. `generation` stays the outgoing key's. 3. `pg_vault_tde_catalog_update_rel_dek()` writes DEK N+1 to the catalog row and hands it to the worker's memory, never to the shared cache. 4. `pg_vault_tde_reencrypt_table()` rewrites every row with DEK N+1. Out-of-line values are fetched back from the TOAST relation first (still compressed), so the toaster stores them again under DEK N+1 and deletes the old chunks β€” reused as they were, they kept DEK N, which the catalog no longer holds (PSQLE-189). 5. A transaction callback moves the cache entry to DEK N+1 at commit (before the locks are released) or back to DEK N at abort, and clears `rotating`. While `rotating` is set nobody installs a current key: every other session encrypts and decrypts with the outgoing DEK, which is what its snapshot of the catalog shows. Every ciphertext carries its generation, so decryption asks for the key of that generation (`pg_vault_tde_kms_get_rel_dek_for_gen()`), and encryption reads DEK and generation in one call (`pg_vault_tde_kms_get_rel_dek_gen()`). - **Bounded staleness**: At most one LWLock pair per encrypt/decrypt call. - **No signals**: Generation mismatch is detected lazily; no SIGUSR1/SIGHUP needed. - **Fork safety**: fork() after `shmem_startup_hook` is safe because the shmem segment is mapped by all backends independently. --- ## Index Access Method (IAM) ### tde_btree The `tde_btree` access method provides a B-Tree index with deterministic (equality-preserving) key encryption using **AES-256-SIV** (Synthetic IV β€” RFC 5297). | Property | Value | |---|---| | Algorithm | AES-256-SIV (deterministic authenticated encryption) | | Key length | 64 bytes (two 32-byte AES keys) | | Equality | Preserved (same plaintext β†’ same ciphertext under same DEK) | | Ordering | **Not preserved** β€” the planner never uses `tde_btree` for ranges, `ORDER BY`, `min`/`max` or merge joins (sequential scan instead); a forced range is an error | | Use case | Equality predicates only (`=`, `IN`, `= ANY`, `ON CONFLICT`) | | Column support | Varlena `bytea`/`text` (`tde_*_ops`; since 1.7.2 no index creation path accepts `numeric` or a nondeterministic collation) and fixed-size `int4`/`int8`/`uuid`/`date`/`timestamptz` (`tde_*_enc_ops`, default since v1.7). All index keys are AES-256-SIV encrypted. | AES-SIV is chosen over AES-GCM for index entries because: - It produces a deterministic ciphertext (required for B-Tree comparisons). - It provides authentication (misuse-resistant β€” no IV to manage). - It prevents key reuse attacks that would be possible with AES-ECB. ### Implementation The implementation uses the OpenSSL 3.x **provider API**: ```c EVP_CIPHER *siv_cipher = EVP_CIPHER_fetch(NULL, "AES-256-SIV", NULL); ``` The DEK (32 bytes) is expanded to 64 bytes for AES-SIV's double-key requirement via PBKDF2-SHA256: ```c PKCS5_PBKDF2_HMAC(dek, TDE_DEK_LEN, (unsigned char *)"tde-siv", 7, 1, /* 1 iteration β€” determinism, not stretching */ EVP_sha256(), 64, siv_key); ``` ### ambuild (sorted bulk-load) The `ambuild` callback uses btree's internal sort layer (`_bt_spoolinit` / `_bt_spool` / `_bt_leafbuild`) via forward-declared prototypes in `pg_vault_tde_iam.c`. These symbols are available at runtime from the postgres binary on all ELF platforms, even though they are not declared in the installed extension dev headers. For each live heap tuple, `tde_build_callback()` encrypts each non-null indexed column datum via `tde_iam_encrypt_index_datum()` (which dispatches to `tde_iam_encrypt_fixed_type_datum()` for fixed-size types and the varlena path otherwise, both AES-256-SIV), then spools it into the btree sort buffer. After the heap scan, `_bt_leafbuild()` writes all encrypted entries to the index pages in sorted order. ### amrescan (query-time key encryption) `pg_vault_tde_amrescan()` encrypts equality scan keys (`sk_strategy == BTEqualStrategyNumber`) with AES-SIV before passing them to the underlying btree scan. A range key (`sk_strategy != 3`) is an error: AES-SIV does not preserve ordering, so walking one against the tree would return wrong rows. The planner never builds such a scan on its own β€” a `get_relation_info_hook` removes the index's sort order and `pg_vault_tde_amcostestimate()` prices non-equality paths out β€” so only a forced plan reaches that error. `amsearcharray` is off: the executor expands `IN (…)` / `= ANY (…)` into one scalar lookup per element. ### Operator Class ```sql -- Registered automatically by CREATE EXTENSION pg_vault_tde CREATE OPERATOR CLASS tde_bytea_ops DEFAULT FOR TYPE bytea USING tde_btree AS OPERATOR 1 < (bytea, bytea), OPERATOR 2 <= (bytea, bytea), OPERATOR 3 = (bytea, bytea), OPERATOR 4 >= (bytea, bytea), OPERATOR 5 > (bytea, bytea), FUNCTION 1 byteacmp(bytea, bytea); ``` `CREATE EXTENSION` registers two families of operator classes: - **Varlena classes** β€” `tde_bytea_ops`, `tde_text_ops`, `tde_numeric_ops` (DEFAULT for their types). The varlena datum is encrypted with AES-256-SIV and stored as `bytea`. - **Fixed-size `enc_ops` classes** (v1.7) β€” `tde_int4_enc_ops`, `tde_int8_enc_ops`, `tde_uuid_enc_ops`, `tde_date_enc_ops`, `tde_timestamptz_enc_ops`, all in the `tde_enc_ops_family` with `STORAGE bytea` and **DEFAULT** for their types. They expose only `OPERATOR 3 (=)` β€” equality is the only meaningful predicate on SIV ciphertext. The legacy classes (`tde_int4_ops`, `tde_int8_ops`, `tde_uuid_ops`, `tde_date_ops`, `tde_timestamptz_ops`) store their keys **in plaintext** and are retained only for indexes already built on them: since 1.7.2 a new index cannot use them unless `pg_vault_tde.allow_plaintext_index = on`, and 1.8 removes them. ### Index-Only Scans Index-only scans are **not supported** on `tde_btree` indexes by design. PostgreSQL index-only scans return column values directly from the index pages without visiting the heap. Since `tde_btree` stores AES-256-SIV ciphertexts as index keys, returning those values directly would expose raw ciphertext to the client with no decryption. All decryption happens in the TAM layer (`decode_slot`) when the heap tuple is fetched. The planner is prevented from choosing an index-only scan path on `tde_btree` indexes; it always fetches the tuple from the `encrypted_heap` table. Range scans, `ORDER BY` and `min()`/`max()` are never served from a `tde_btree` index β€” AES-256-SIV does not preserve ordering regardless of column type β€” and run as sequential scans instead. ### Usage Example ```sql -- Create an encrypted table CREATE TABLE employees ( id int4, username text, salary numeric ) USING encrypted_heap; -- Create tde_btree indexes on multiple column types. The opclass is optional: -- the encrypted-key classes are the DEFAULT for each type since v1.7, so -- `USING tde_btree (id)` picks tde_int4_enc_ops automatically. CREATE INDEX employees_id_idx ON employees USING tde_btree (id tde_int4_enc_ops); CREATE INDEX employees_username_idx ON employees USING tde_btree (username tde_text_ops); -- Equality lookups use the encrypted index INSERT INTO employees VALUES (1, 'alice', 90000); INSERT INTO employees VALUES (2, 'bob', 85000); SELECT salary FROM employees WHERE id = 1; -- uses index SELECT id FROM employees WHERE username = 'alice'; -- uses index -- Range predicates run as a sequential scan: tde_btree answers equality only SELECT * FROM employees WHERE id > 1; -- seq scan, not index scan -- Index-only scans are not supported and never chosen by the planner; -- the heap tuple is always fetched to decrypt column values. ``` --- ## Logical Decoding and Replication `pg_vault_tde` ships a logical decoding output plugin (`pg_vault_tde_pgoutput`) so that `encrypted_heap` tables can be published to logical replication subscribers **in plaintext**, even though their on-disk tuples β€” and their WAL β€” are ciphertext. ### Why a plugin is needed The TAM decrypt-on-read callbacks run in the query executor, not in the WAL sender. A logical decoder reads raw WAL records whose tuple bodies are ciphertext, so without intervention a subscriber receives encrypted garbage. ### Non-TOAST tables β€” pgoutput wrapper `_PG_output_plugin_init` loads the built-in `pgoutput` via `load_external_function()`, lets it populate every callback, then overrides the change callbacks with thin wrappers that decrypt the tuple **in place** (via `tde_decrypt_heap_tuple`) before delegating back to `pgoutput` for the actual serialization. Because the emitted wire format is exactly the `pgoutput` protocol, this works with `pg_recvlogical` and with a native `CREATE SUBSCRIPTION` pointed at a slot created with this plugin. (Same wrapping technique as Citus's CDC decoder.) ### TOAST columns β€” custom WAL resource manager Externally-TOASTed columns need more than in-place decryption: the core reorder buffer reassembles a TOAST value by `heap_deform_tuple()`-ing the chunks and the main tuple **before any output-plugin callback runs**, and on an encrypted tuple that crashes (`got sequence entry … for toast chunk`). There is no extension hook earlier than that point. The lever that does exist is a **custom WAL resource manager**, gated by the GUC `pg_vault_tde.toast_custom_rmgr` (PGC_POSTMASTER, default **off**; requires `pg_vault_tde` in `shared_preload_libraries`). When enabled: 1. **Write path** β€” `tde_toast_wal_insert()` (a faithful clone of `heap_insert`) logs encrypted TOAST chunks under `TDE_RMGR_ID` (161, registered for pg_vault_tde on the PostgreSQL *Custom WAL Resource Managers* wiki) instead of `RM_HEAP_ID`. The WAL record is byte-identical to heap's except for the resource manager id, so crash recovery is unaffected (`rm_redo` delegates to `heap_redo`). 2. **Decode** β€” routing the chunks to our `rm_decode` keeps them out of the reorder buffer's `toast_hash`, so the core never deforms the still-encrypted main tuple. `rm_decode` captures the raw encrypted chunks per transaction (no catalog access during decode). 3. **Stitch** β€” `tde_toast_stitch()`, called from the plugin's change callback after the main tuple has been decrypted, decrypts the captured chunks, reconstructs the plaintext value, and rewrites the external on-disk TOAST pointers into in-memory indirect pointers β€” a faithful analogue of core's `ReorderBufferToastReplace()`. `pgoutput` then serializes the full plaintext. ### Server configuration PostgreSQL 17.11 / 18.x β€” and the matching minors of the older back branches β€” only load a library as a logical decoding output plugin if it is listed in the `output_plugin_libraries` GUC (default `pgoutput, test_decoding`). On those versions the publisher must be told to accept this plugin, otherwise slot creation fails with: ``` ERROR: library "pg_vault_tde" may not be used as an output plugin HINT: ... add it to "output_plugin_libraries" and reload the server configuration. ``` ```conf # postgresql.conf on the publisher (PGC_SUSET β€” a reload is enough) output_plugin_libraries = 'pgoutput, pg_vault_tde' ``` It must be set in the server configuration, not in a session: the process that loads the plugin is the walsender, not the client backend. Earlier minors have no such GUC β€” and an unrecognised parameter in `postgresql.conf` is fatal at startup β€” so add the line only where `pg_settings` reports it. ### Requirements and supported operations | Operation | Requirement | |-----------|-------------| | INSERT (inline, TOAST, bursts) | `toast_custom_rmgr = on` for TOAST columns | | Initial table sync (COPY) | works via the TAM read path (decrypt-on-read) | | UPDATE / DELETE | **`REPLICA IDENTITY FULL` + a primary key** | `REPLICA IDENTITY FULL` is mandatory for UPDATE/DELETE: with `DEFAULT` the core derives the replica identity by reading the **encrypted** old tuple as if it were the key, producing a constant garbage key β€” the subscriber then silently targets the wrong row. Tables without a primary key are likewise unsupported for UPDATE/DELETE (no key to match on). These are documented limitations, not bugs: they follow from the tuple being an opaque ciphertext blob to the core. ### Structural limitations - **`heap_insert` clone maintenance** β€” `tde_toast_wal_insert()` mirrors `heap_insert()` and must be re-synced on each major PostgreSQL release; it is version-audited against the upstream function (see the comment in `src/logical/pg_vault_tde_rmgr.c`). - **Reorder-buffer coupling** β€” the stitch path mirrors internal contracts of `ReorderBufferToastReplace` (buffer copy-back, memory context) that are not a stable public API. - **Slots behind a key rotation** β€” a rotation is decoded as one UPDATE per row, and WAL written under the previous DEK generation decodes only while that key is still in shared memory (the catalog keeps the current one only). A slot that has not decoded it when the publisher restarts, or when the same table is rotated again, fails with `pg_vault_tde: decryption failed` at the same LSN on every retry β€” every slot of the database, since the plugin decrypts before pgoutput filters by publication. Let slots confirm past a rotation first; persisting previous keys is the 1.8 key ring. - **Aborted-transaction capture** β€” a TOAST-writing transaction that reaches a full snapshot and then aborts *without being streamed* leaves its captured chunks in memory until the decoding process exits (there is no output-plugin hook for non-streamed aborts; it is a slow, per-abort leak, not per-row). This ciphertext-as-opaque-blob conflict β€” every place the core reads a single column (e.g. replica identity) sees ciphertext β€” is the motivation for the column-level encryption alternative on the v1.8 roadmap. --- ## PKCS#11 / HSM Provider The `pkcs11` KMS provider (v1.7, `src/kms/pg_vault_tde_kms_pkcs11.c`) keeps the KEK inside a hardware security module. It talks the Cryptoki API directly: the vendor's PKCS#11 module (`pg_vault_tde.pkcs11_library`) is `dlopen()`ed at runtime and every DEK is wrapped/unwrapped with `C_WrapKey`/`C_UnwrapKey` using `CKM_AES_KEY_WRAP` (RFC 3394, the same algorithm the local wallet provider uses in software), with a runtime fallback to `CKM_AES_KEY_WRAP_PAD`. No OpenSSL involvement and no build/runtime dependency: the OASIS interface headers are vendored under `src/include/pkcs11/` (include them only through `src/include/pg_vault_tde_cryptoki.h`). ### Key Hierarchy and Threat Model ``` HSM token (user PIN via env var) └── KEK: AES-256, CKO_SECRET_KEY, CKA_SENSITIVE, CKA_EXTRACTABLE=FALSE └── C_WrapKey (CKM_AES_KEY_WRAP) β†’ per-table DEK (40-byte blob β”‚ in pg_vault_tde_catalog) └── encrypts tuple data (AES-256-GCM, in-process) ``` Only the **KEK** is confined to the HSM: tuple crypto runs in-process, so the plaintext DEK necessarily transits backend memory (stack buffers, `OPENSSL_cleanse`d after use) β€” the same model as the Vault Transit provider. An attacker with the disk (or a catalog dump) holds only DEKs wrapped by a key that exists exclusively inside the device. ### Setup 1. `pg_vault_tde.kms_provider = 'pkcs11'`, `pkcs11_library`, and `pkcs11_token_label` (preferred; `pkcs11_slot_id` is the fallback β€” slot IDs are not stable across restarts on some modules). 2. Export the token user PIN in the environment variable named by `pkcs11_pin_env` (default `PG_TDE_PKCS11_PIN`) before starting PostgreSQL. The GUC holds the env var *name* β€” never put the PIN in `postgresql.conf`. 3. `SELECT pg_vault_tde_pkcs11_keygen();` (superuser, once) generates the AES-256 KEK on the token under `pkcs11_key_label`. It refuses to overwrite an existing key. Alternatively provision the key with the HSM tooling (`CKA_WRAP`, `CKA_UNWRAP`, `CKA_EXTRACTABLE=FALSE`). Per-database HSM isolation works like every other provider: all `pkcs11_*` GUCs are `PGC_SUSET`, so different databases can use different tokens or key labels via `ALTER DATABASE ... SET`. ### Process Model and Fork Safety PKCS#11 (Β§6.6 of the spec) makes Cryptoki state unusable across `fork()`. Because every PostgreSQL backend is forked from the postmaster: - `C_Initialize` is **never** called in the postmaster β€” `init()` there only validates the GUCs; - each backend attaches lazily on first use (dlopen β†’ `C_Initialize` β†’ slot discovery β†’ `C_OpenSession` β†’ `C_Login` β†’ KEK lookup), and a `getpid()` guard discards any state inherited across fork without calling into the module; - on session/device loss (`CKR_SESSION_HANDLE_INVALID`, `CKR_DEVICE_ERROR`, ...) operations retry exactly once through a fresh session; - the vendor module is never `dlclose()`d (many modules crash on unload). ### KEK Rotation Each KEK generation lives forever under its own immutable token label `