# 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. [KMS and Key Caching](#kms-and-key-caching) 5. [Index Access Method (IAM)](#index-access-method) 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 | ### 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 ``` 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 relid; single shared β”‚ LWLock; generation + prev_dek window] β”‚ DEK (32 bytes) copied into a stack buffer on every call; the crypto β”‚ layer caches the AES key schedule keyed by (relid, generation) β–Ό tde_gcm_encrypt() / tde_gcm_decrypt() [src/crypto/pg_vault_tde_crypto.c] β”‚ β–Ό Disk: [HeapTupleHeader | IV(12) | Ciphertext | GCM-TAG(16) | VER(1) | GEN(8)] ``` ### Shared Memory Layout Since v1.7 the cache is a shared-memory **hash table** (`HTAB`) keyed by `relid`, not a fixed array scanned linearly. Each entry is one `TdeRelDekMap`: ```c /* Per-relation DEK entry β€” value type of the TdeRelDekMap HTAB (v1.5+) */ typedef struct TdeRelDekMap { Oid relid; /* 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; ``` - `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("TdeRelDekMap", 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))`. - There is **no per-entry lock**. A single `LWLock` (file-scope `rel_dek_lock`) from a **named** tranche guards the whole table: `RequestNamedLWLockTranche("TdeRelDekMap", 1)` in the `shmem_request_hook`, then `&GetNamedLWLockTranche("TdeRelDekMap")[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 | Selects `encrypted_heap` as the TOAST AM when `pg_vault_tde.toast_encryption = on` (default), so TOAST chunks are encrypted through the same `tuple_insert`/`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) { (void) rel; if (pg_vault_tde_toast_encryption) { Oid encheap_oid = get_table_am_oid("encrypted_heap", true); if (OidIsValid(encheap_oid)) return encheap_oid; } return HEAP_TABLE_AM_OID; } ``` When `pg_vault_tde.toast_encryption = on` (the default), 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. When `pg_vault_tde.toast_encryption = off`, TOAST tables fall back to standard `heap` AM, leaving large column values stored unencrypted β€” a configuration intentionally supported for performance-sensitive workloads where only the tuple body (not TOAST chunks) needs confidentiality protection. ### 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 4** is the **only** on-disk tuple format. It is an **IV-first trailer** layout: the version byte and generation counter sit at the **end** of the blob, so the data differs from byte 0 on every encryption (this is what disables HOT β€” see [Known Limitations](#known-limitations)). 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).) ``` +----------+----------------------------+----------+-------+----------+ | IV | CIPHERTEXT | GCM TAG | VER | GEN | | 12 bytes | N bytes (= plaintext len) | 16 bytes | 1 byte| 8 bytes | +----------+----------------------------+----------+-------+----------+ random 0x04 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`). 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 void tde_next_iv(unsigned char *iv_out) { if (iv_batch_pos >= TDE_IV_BATCH_SIZE) { if (!pg_strong_random(iv_batch, TDE_IV_BATCH_BYTES)) ereport(ERROR, (errmsg("[CRYPTO] pg_strong_random failed"))); iv_batch_pos = 0; } 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. ### 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 now **per-relation** via `pg_vault_tde_rotate_online(relname, batch_size)`. For each encrypted relation `pg_vault_tde_catalog_zero_rel_dek()`: 1. Acquires `LW_EXCLUSIVE` on the single `rel_dek_lock` guarding the HTAB and looks the entry up with `hash_search(HASH_FIND)`. 2. Promotes the current DEK to `prev_dek` then `OPENSSL_cleanse`s `dek[32]` for the rotation window. 3. Increments the per-relation `generation` counter. 4. Sets `dek_valid = false` (triggers a catalog read + KMS unwrap on next access). 5. Releases lock. Each backend detects the mismatch lazily on the next encrypt/decrypt call for that relation. Old-generation rows can still be read via `prev_dek` during the rotation window; after `pg_vault_tde_reencrypt_table()` completes the window closes. - **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** β€” range scans return empty results | | Use case | Equality predicates only (`=`, `IN`, `ON CONFLICT`) | | Column support | Varlena `bytea`/`text`/`numeric` (`tde_*_ops`) 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. Range keys (`sk_strategy != 3`) are passed through unchanged β€” they will produce empty results because AES-SIV does not preserve ordering. ### 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 non-encrypted classes (`tde_int4_ops`, `tde_int8_ops`, `tde_uuid_ops`, `tde_date_ops`, `tde_timestamptz_ops`) are retained but **not** default; prefer the `enc_ops` classes so index keys are encrypted. ### 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 on `tde_btree` columns return empty results by design β€” AES-256-SIV does not preserve ordering regardless of column type. ### 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 fall back to sequential scan (index returns empty by design) 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` 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. ### 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. - **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). - **Resource manager id** β€” the experimental id `RM_EXPERIMENTAL_ID` (128) is used for now; a stable custom rmid will be reserved and registered on the PostgreSQL community wiki before GA. 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 `