Contents
- pg_vault_tde Technical Reference
- Table of Contents
- Architecture
- Table Access Method
- Crypto Layer
- Performance
- Index Access Method (IAM)
- Logical Decoding and Replication
- PKCS#11 / HSM Provider
- Known Limitations
- Security Considerations
- Wire Format Reference
- SQL API Reference
- Extension Initialization
- Testing Strategy
- Packaging
- Roadmap
- Contributing
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
- Architecture
- Table Access Method (TAM)
- Crypto Layer
- KMS and Key Caching
- Index Access Method (IAM)
- Logical Decoding and Replication
- PKCS#11 / HSM Provider
- Known Limitations
- Security Considerations
- Wire Format Reference
- SQL API Reference
- Extension Initialization
- Testing Strategy
- Packaging
- Roadmap
- 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:
/* 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_LENis defined only insrc/include/pg_vault_tde_kms.h.- The HTAB lives in
src/kms/pg_vault_tde_catalog.c, created withShmemInitHash("TdeRelDekMap", capacity, capacity, &info, HASH_ELEM | HASH_BLOBS)wherecapacity = pg_vault_tde.max_encrypted_relations. The segment is sized withhash_estimate_size(capacity, sizeof(TdeRelDekMap)). - There is no per-entry lock. A single
LWLock(file-scoperel_dek_lock) from a named tranche guards the whole table:RequestNamedLWLockTranche("TdeRelDekMap", 1)in theshmem_request_hook, then&GetNamedLWLockTranche("TdeRelDekMap")[0].lockin theshmem_startup_hook. The lock is takenLW_SHAREDfor lookups andLW_EXCLUSIVEfor insert/evict/rotate. - A second, fixed-size shmem struct (
pg_vault_tde_kms_cache, inpg_vault_tde_kms.c) holds the shared Vault token. Its lock uses a dynamic tranche (LWLockNewTrancheId()), which is why that call lives in theshmem_startup_hookand not_PG_init— see Extension Initialization.
Table Access Method
Design Pattern: Mutable Copy of heapam
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:
- Casts the slot to
BufferHeapTupleTableSlot(known buffer-backed after heapam) - Guards against double-decode: checks
bslot->buffer == InvalidBuffer - Saves
bslot->base.tuple->t_self(physical TID) andt_tableOid(relation OID) from the buffer page - Calls
tde_decrypt_heap_tuple(bslot->base.tuple, saved_tableoid)— decrypts the buffer-backed tuple directly (verifies GCM tag via OpenSSL). Noheap_copytupleand no explicitExecClearTuple: 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). - Stamps
plain->t_self = saved_tid,plain->t_tableOid = saved_tableoid - Calls
ExecForceStoreHeapTuple(plain, slot, true)— this performs the single internalExecClearTuplethat releases the buffer pin (the only release point) - Manually sets
slot->tts_tid = saved_tid—ExecForceStoreHeapTupledoes NOT restoretts_tidfor buffer slots; it must be set explicitly
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:
index_fetch_tuple— callsheap_hot_search_bufferon every index lookupindex_build_range_scan— callsheap_getnextinternally duringCREATE 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
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:
/* 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/urandomwrapper) — NOTRAND_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). 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.)
+----------+----------------------------+----------+-------+----------+
| 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_cleansed beforepfree. - Plaintext
HeapTupleintermediates areOPENSSL_cleansed after encryption. - Per-backend EVP contexts are freed via
on_proc_exit()callbacks (tde_iam_ctx_cleanupfor AES-SIV; analogous cleanup for GCM contexts). - Shared-memory DEK is
OPENSSL_cleansed 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:
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:
#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 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 │
└────────────────────────────────────────────────────┘
- Every encrypt/decrypt call fetches the DEK with
pg_vault_tde_kms_get_rel_dek():hash_search(HASH_FIND)underLW_SHARED,memcpyinto a stack buffer, release. The buffer isOPENSSL_cleansed after use (caller responsibility). - 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 underLW_EXCLUSIVE. - Cross-call key-schedule reuse lives in the crypto layer, not here: the
TdeCipherSlotEVP contexts cache the installed AES schedule keyed by(relid, generation)(see Per-Backend EVP Contexts), 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_cleanses 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_hookis 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:
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:
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
-- 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 asbytea. - Fixed-size
enc_opsclasses (v1.7) —tde_int4_enc_ops,tde_int8_enc_ops,tde_uuid_enc_ops,tde_date_enc_ops,tde_timestamptz_enc_ops, all in thetde_enc_ops_familywithSTORAGE byteaand DEFAULT for their types. They expose onlyOPERATOR 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 theenc_opsclasses 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
-- 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:
- Write path —
tde_toast_wal_insert()(a faithful clone ofheap_insert) logs encrypted TOAST chunks underTDE_RMGR_IDinstead ofRM_HEAP_ID. The WAL record is byte-identical to heap’s except for the resource manager id, so crash recovery is unaffected (rm_redodelegates toheap_redo). - Decode — routing the chunks to our
rm_decodekeeps them out of the reorder buffer’stoast_hash, so the core never deforms the still-encrypted main tuple.rm_decodecaptures the raw encrypted chunks per transaction (no catalog access during decode). - 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’sReorderBufferToastReplace().pgoutputthen 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_insertclone maintenance —tde_toast_wal_insert()mirrorsheap_insert()and must be re-synced on each major PostgreSQL release; it is version-audited against the upstream function (see the comment insrc/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_cleansed 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
pg_vault_tde.kms_provider = 'pkcs11',pkcs11_library, andpkcs11_token_label(preferred;pkcs11_slot_idis the fallback — slot IDs are not stable across restarts on some modules).- Export the token user PIN in the environment variable named by
pkcs11_pin_env(defaultPG_TDE_PKCS11_PIN) before starting PostgreSQL. The GUC holds the env var name — never put the PIN inpostgresql.conf. SELECT pg_vault_tde_pkcs11_keygen();(superuser, once) generates the AES-256 KEK on the token underpkcs11_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_Initializeis 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 agetpid()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
<label>.v<N> (N monotonically increasing) — rotation never renames or
destroys a key. “Current” is simply the highest N found on the token, and
every wrapped DEK stored in the catalog carries a 4-byte version tag
identifying exactly which <label>.v<N> produced it. Unwrap always looks
up that exact version, regardless of which one is “current” at the time.
SELECT pg_vault_tde_rotate_kek(); drives:
- prepare — generates a fresh KEK as
<label>.v<current+1>; - rewrap — every catalog DEK is unwrapped with the KEK version tagged in its own blob and re-wrapped with the new version, tagging the new blob accordingly (transactional catalog UPDATEs);
- commit — purely an in-backend cache update (the new version is already durably on the token and every rewrapped row already carries its own version tag), so there is nothing left to make durable and no crash window: whatever the catalog transaction ends up committing is self-describing and always resolves to the right KEK.
Because no KEK generation is ever renamed or destroyed, a crash or a rolled
back rotation at any point simply leaves an unused <label>.v<N+1> key on
the token (harmless — the next rotation attempt reuses or supersedes it)
with the catalog untouched, still tagged with the old version and still
fully readable.
Cross-backend propagation. commit_kek_rotation only updates the
cache of the ONE backend that ran pg_vault_tde_rotate_kek(). Without more,
every OTHER already-connected backend would keep wrapping new DEKs under
the pre-rotation KEK indefinitely. A small shared-memory beacon (one
LWLock + a uint32 KEK version, same dynamic-tranche pattern as the
Vault token cache) fixes this: commit_kek_rotation (and the initial
keygen) publish the new version there; every backend checks it
opportunistically on its own next wrap/unwrap/rewrap call and, if stale,
resolves its own CK_OBJECT_HANDLE locally via a label lookup. Only the
version NUMBER crosses the process boundary — never the object handle
itself, which PKCS#11 only guarantees meaningful within the session that
resolved it. Staleness for an already-attached backend is therefore bounded
by “its own next operation”, not by wall-clock time or a reconnect.
Testing with SoftHSM2
tap/16_pkcs11.t is fully self-contained: it provisions a throwaway
SoftHSM2 token in a tempdir (SOFTHSM2_CONF + softhsm2-util
--init-token, no root needed) and exercises keygen, round-trip, on-disk
ciphertext, restart, health check, KEK rotation, cross-backend rotation
propagation (a long-lived session picking up a rotation committed by a
different connection, via background_psql), and clean failure paths
(wrong token label, wrong PIN, unprovisioned key label, invalid
pkcs11_library path) — 19 assertions in total.
Run it via make ci-pkcs11 (containerized) or prove tap/16_pkcs11.t
where the softhsm2 package is installed; it skips itself otherwise.
pkcs11-tool (package opensc) is handy for inspecting the token:
pkcs11-tool --module /usr/lib/softhsm/libsofthsm2.so --login --list-objects.
Limitations
- The standalone backup tools (
pg_dump_tde/pg_restore_tde) do not supportkms_provider = 'pkcs11'yet; they exit with a clear error. health_check()may exceed its usual latency budget on first touch of a network HSM (the lazy attach performs the full login sequence).- PKCS#11 labels are not unique: keep exactly one KEK under
pkcs11_key_label(the provider warns and picks the first match).
Known Limitations
Current Limitations (v1.7 — Current Release)
| # | Limitation | Fix Version |
|---|---|---|
| 1 | TOAST chunk-level storage encryption — ✅ Resolved in v1.6: large values round-trip fully encrypted via pg_vault_tde_toast_am returning encrypted_heap AM. Disable with pg_vault_tde.toast_encryption = off for legacy behaviour. |
v1.6 ✅ |
| 2 | tde_btree fixed-size types plaintext index keys — ✅ Resolved in v1.7: int4, int8, uuid, date, timestamptz btree index keys are now encrypted with AES-256-SIV, matching varlena type behaviour. |
v1.7 ✅ |
| 3 | Logical replication of TOAST columns — ✅ Resolved in v1.7 via the custom WAL resource manager (enable pg_vault_tde.toast_custom_rmgr). UPDATE/DELETE require REPLICA IDENTITY FULL + a primary key; REPLICA IDENTITY DEFAULT and PK-less tables remain unsupported. See Logical Decoding and Replication. |
v1.7 ✅ |
| 4 | WAL unencrypted — requires XLogInsert() hook unavailable in extension API |
Permanently deferred |
| 5 | All-or-nothing table encryption — no per-column granularity | v1.8 |
| 6 | Range scans on tde_btree — WHERE col > x returns empty (AES-SIV not order-preserving) |
By design, permanent |
| 7 | BRIN on encrypted columns — min/max of AES-SIV ciphertexts is meaningless | By design, permanent |
| 8 | HOT updates disabled — heap_update reject to use HOT updates because the wire format portion considerd by TupDesc for the comparison between old and new tuple is non-deterministic aka changes at every encryption |
By design, permanent |
| 9 | WITH HOLD cursor plaintext temp file — a held cursor’s result set is materialized into a tuplestore at COMMIT and spills to a plain temp file on disk past work_mem, bypassing the TAM entirely; no extension hook exists anywhere in the WITH HOLD cursor lifecycle to intercept it. See README.md § Limitations item 6. |
Permanently deferred |
| 10 | Plain COPY <table> TO / pg_dump produce a plaintext dump, with no warning — encryption lives entirely in the TAM’s read callbacks (scan_getnextslot and friends), which decrypt unconditionally and cannot distinguish a COPY TO from a SELECT; pg_dump’s default table-data path is exactly this form of COPY. No ProcessUtility_hook guard or GUC-gated WARNING exists yet (designed, never implemented). Use pg_dump_tde/pg_restore_tde instead. See README.md § Limitations item 10. |
v1.8 |
HOT updates are disabled by design (v4 IV-first wire format)
On an encrypted_heap table, heap_update never chooses a HOT (heap-only tuple)
update: every UPDATE writes new index entries. This is intentional and is what
keeps tde_btree indexes coherent across UPDATEs of indexed columns — the index always
follows the row to its new key, with no REINDEX needed.
Mechanism. heap_update decides whether an update can be HOT by comparing the
indexed columns byte-for-byte between the old and new tuple image. On an encrypted_heap
table both images are the encrypted wire format. The v4 layout (see
Wire Format per Encrypted Region) is IV-first:
it begins with the random GCM IV, which is freshly generated on every encryption. The
encrypted image therefore differs from byte 0 for any re-encryption — including when
the plaintext is unchanged — so heap_update always sees the indexed column as modified
and skips the HOT path. The constant [VERSION | GENERATION] bytes were moved to the
end of the blob precisely so they fall outside the comparison window.
Historical note (v3 bug, fixed in v4). The previous v3 format placed a constant
[VERSION(1)=0x03 | GENERATION(8)]prefix first. An indexed column whose datum landed inside that 9-byte prefix — typically a leading fixed-widthint4/int8key — looked unchanged toheap_update, which then chose a HOT update and silently skipped the index maintenance, leaving the index pointing at the old key. Moving the constant bytes to the trailer removed the byte-stable region and resolved the bug structurally; the workarounds that v3 required (REINDEX, or arranging the indexed column past the first 9 bytes) are no longer needed.
Historical Limitations (v1.0) — Many Resolved Since
TOAST encryption (ticket #1) — ✅ Resolved in 6
pg_vault_tde_toast_amnow returnsencrypted_heapAM whenpg_vault_tde.toast_encryption = on(default). Every TOAST chunk is encrypted individually using the parent relation’s DEK. The v1.0 behaviour (forcedHEAP_TABLE_AM_OID) is available viatoast_encryption = off.Row re-encryption after rotation (ticket #2) — ✅ Resolved
pg_vault_tde_rotate_online(relname, batch_size)promotes the current DEK toprev_dekand bumps the per-relation generation; old-generation rows stay readable viaprev_dekduring the rotation window.pg_vault_tde_reencrypt_table(regclass [, batch_size])(implemented insrc/tam/pg_vault_tde_tam.c) then rewrites every row to the new generation in batches, closing the window. A rotation background worker (src/kms/pg_vault_tde_rotation_bgw.c) can drive this automatically.Vault HTTP connector (ticket #3) — ✅ Resolved
The libcurl-based Vault/OpenBao Transit integration is fully implemented insrc/kms/pg_vault_tde_kms.c(vault_provider_wrap_dek()/vault_provider_unwrap_dek()/vault_provider_rewrap_dek(), synchronous viavault_transit_request()). Supports token, AppRole, and Kubernetes JWT auth.multi_insert / COPY throughput (ticket #4) The
multi_insertcallback encrypts per-slot and callsheap_insertin a loop, forfeiting WAL-batching optimizations. This degrades COPY workloads by roughly the single-insert overhead multiplied by batch size. Batch-encryptedheap_multi_insertis targeted for v1.1.Logical replication (ticket #5) — ✅ Resolved (non-TOAST in v1.2, TOAST columns in v1.7)
Thepg_vault_tde_pgoutputplugin decrypts tuples before publishing to subscribers; the custom WAL resource manager (pg_vault_tde.toast_custom_rmgr) extends this to externally-TOASTed columns. See Logical Decoding and Replication.IAM range scans (ticket #6)
WHERE col > 'x'on a column with atde_btreeindex always returns empty: AES-SIV does not preserve ordering. Users requiring range predicates on encrypted columns must use sequential scans.
Security Considerations
Threat Model
pg_vault_tde encrypts data at rest (relation files, TOAST files, backup media, standby base backups). It does not protect:
- In-memory tuple data during query execution.
- WAL structural metadata (tuple payloads in WAL are ciphertext; LSN, block numbers, and relation OIDs are plaintext).
pg_statisticrows written by ANALYZE (see below).- Network connections between backends and clients (use
ssl = oninpg_hba.conf).
Planner Statistics
pg_statistic is populated by ANALYZE after decryption through the TAM
read path. Statistics are stored as plaintext in the system catalog.
Protect via:
sql
REVOKE SELECT ON TABLE pg_statistic FROM PUBLIC;
WAL
Tuple payload bytes in WAL are the encrypted bytes written to disk — a WAL stream viewer sees ciphertext in DATA positions. Structural metadata (LSN, block numbers, relation OID, MVCC fields) is plaintext.
Superuser Bypass
A PostgreSQL superuser executing SQL sees plaintext (decrypted through the TAM layer). A superuser with OS-level file access sees encrypted content. Row-level security and column-level privileges complement TDE for access control but do not replace it.
Key Material Lifecycle
| Event | Action |
|---|---|
| Backend start | Local DEK copy in TopMemoryContext |
| Query end | DEK remains in TopMemoryContext (not wiped per-query) |
pg_vault_tde_rotate_online() |
Per-relation shmem DEK OPENSSL_cleansed; generation incremented |
| Backend exit | on_proc_exit hook calls OPENSSL_cleanse on local copy |
| OS crash | Shmem lost; DEK must be re-injected from Vault on restart |
Wire Format Reference
Per-Page Layout
┌────────────────────────────────────────────────────────────────────┐
│ PageHeaderData (24 bytes, plain) │
│ ItemId array (4 bytes per slot, plain) │
├────────────────────────────────────────────────────────────────────┤
│ Free space │
├────────────────────────────────────────────────────────────────────┤
│ ... tuples grow downward from end of page ... │
│ │
│ ┌─────────────────────────────┬─────────────────────────────────┐ │
│ │ HeapTupleHeaderData │IV(12)│CT│TAG(16)│VER(1)│GEN(8)│ │
│ │ (t_hoff bytes, PLAINTEXT) │ │ │
│ └─────────────────────────────┴─────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────────┘
Constants
| Constant | Value | Defined in |
|---|---|---|
TDE_GCM_IV_LEN |
12 | pg_vault_tde_crypto.h |
TDE_GCM_TAG_LEN |
16 | pg_vault_tde_crypto.h |
TDE_V4_VERSION_BYTE |
0x04 |
pg_vault_tde_crypto.h |
TDE_V4_GEN_LEN |
8 | pg_vault_tde_crypto.h |
TDE_V4_AAD_LEN |
16 | pg_vault_tde_crypto.h (dboid + relid + generation) |
TDE_V4_OVERHEAD |
37 | pg_vault_tde_crypto.h (= 12 + 16 + 1 + 8) |
TDE_DEK_LEN |
32 | pg_vault_tde_kms.h only |
TDE_DEK_LEN MUST NOT be redefined in any .c file or other header
(header hygiene rule).
SQL API Reference
Access Methods
-- Table AM: encrypts all column data per tuple
CREATE TABLE t (...) USING encrypted_heap;
-- Index AM: deterministic AES-SIV for B-Tree key equality
CREATE INDEX ON t USING tde_btree (col);
GUC Parameters
All parameters are in the pg_vault_tde namespace and are registered in
_PG_init via DefineCustomXxxVariable.
Context: all KMS-related parameters are PGC_SUSET — superusers can set
them at session level or scope them to individual databases with
ALTER DATABASE SET / ALTER ROLE SET. No server restart is needed.
The exceptions are max_encrypted_relations (controls shared-memory
sizing, PGC_POSTMASTER), crypto_provider (OpenSSL provider selection,
PGC_POSTMASTER), and enabled (master crypto switch, PGC_POSTMASTER —
its value is baked into the on-disk wire format, so it cannot be toggled
without risking silent plaintext/ciphertext mismatches; see the enabled
row below).
Per-Database KMS Configuration
Because all GUCs are PGC_SUSET, each database in the same PostgreSQL cluster
can independently select its KMS backend and credentials. This is the primary
mechanism for multi-tenant key isolation:
-- cluster-wide default (postgresql.conf or ALTER SYSTEM)
-- pg_vault_tde.kms_provider = 'vault'
-- tenant_a: dedicated Transit key, no change to other settings
ALTER DATABASE tenant_a SET pg_vault_tde.vault_key_name = 'tde-dek-a';
ALTER DATABASE tenant_a SET pg_vault_tde.vault_transit_mount = 'transit-tenants';
-- tenant_b: offline local wallet, completely different backend
ALTER DATABASE tenant_b SET pg_vault_tde.kms_provider = 'local';
ALTER DATABASE tenant_b SET pg_vault_tde.wallet_passphrase_env = 'TDE_WALLET_B';
-- verify effective configuration
\connect tenant_b
SHOW pg_vault_tde.kms_provider; -- 'local'
SELECT * FROM pg_vault_tde_health_check();
Settings applied with ALTER DATABASE SET take effect for new connections to
that database. The provider is selected per-connection from the effective GUC
value; no shared state is changed.
| Parameter | Type | Default | Context | Description |
|---|---|---|---|---|
kms_provider |
string | '' (unset — must be configured) |
suset | Active KMS backend: vault, local (v1.6). Settable per-database. |
vault_url |
string | '' |
suset | Vault / OpenBao base URL |
vault_namespace |
string | '' |
suset | Vault namespace (enterprise; empty for community) |
vault_auth_method |
string | token |
suset | Vault auth method: token, approle, or kubernetes |
vault_token |
string | '' |
suset | Auth token — hidden from pg_settings (GUC_NOT_IN_SAMPLE) |
vault_role_id |
string | '' |
suset | AppRole role_id UUID — hidden from pg_settings |
vault_secret_id |
string | '' |
suset | AppRole secret_id — hidden from pg_settings |
vault_role_name |
string | '' |
suset | AppRole role name for secret_id rotation (v1.4) — calls secret-id/destroy after login |
vault_k8s_role |
string | '' |
suset | Kubernetes JWT auth role name |
vault_k8s_mount |
string | kubernetes |
suset | Kubernetes auth engine mount path |
vault_transit_mount |
string | transit |
suset | Transit secrets engine mount path |
vault_key_name |
string | pg-tde-dek |
suset | Transit key name for DEK wrapping. Override per-database to isolate tenant keys. |
vault_ca_cert |
string | '' |
suset | Path to CA bundle for Vault TLS (CURLOPT_CAINFO) |
vault_timeout_ms |
integer | 5000 |
suset | Vault HTTP timeout in ms (0 = no timeout; range 0–300000) |
wallet_path |
string | /var/lib/pg_vault_tde/<OID>/wallet.p12 |
suset | Local wallet PKCS#12 path (kms_provider = 'local') |
wallet_passphrase_env |
string | '' |
suset | Env var NAME holding the wallet passphrase |
wallet_passphrase_file |
string | '' |
suset | File path containing the wallet passphrase (mode 0400 enforced) |
wallet_passphrase_command |
string | '' |
suset | Shell command whose stdout is the passphrase (highest priority) |
wallet_auto_open |
boolean | on |
suset | Auto-open wallet at startup if passphrase env var is set |
dev_mode |
boolean | off |
suset | Enable development-only conveniences (insecure in production) |
wallet_dev_mode_passphrase |
string | '' |
suset | Inline dev passphrase, used only when dev_mode = on; emits a WARNING on every use — hidden from pg_settings |
dek_cache_ttl |
integer | 0 |
suset | Per-backend DEK cache TTL in seconds (0 = no expiry; range 0–86400). When > 0, each backend re-reads the DEK from shmem after this interval even without rotation |
toast_encryption |
boolean | on |
suset | Encrypt TOAST chunks with the parent relation’s DEK |
allow_plaintext_index |
boolean | off |
suset | When off (default), CREATE INDEX/CREATE UNIQUE INDEX with a non-tde_btree access method on an encrypted_heap table is rejected with ERROR. When on, the same statement is allowed after a WARNING — the indexed column’s plaintext value is then stored unencrypted on disk. Does not affect PRIMARY KEY/UNIQUE table constraints, which PostgreSQL core always backs with a native btree index regardless of this setting (that case already warns unconditionally). |
bgw_enabled |
boolean | off |
suset | Enable background worker for automatic token renewal. Requires cluster restart: the BGW is registered via RegisterBackgroundWorker() at postmaster startup; changing via pg_reload_conf() updates the value but does not start/stop the worker dynamically. |
token_renewal_interval |
integer | 3600 |
suset | Token renewal interval in seconds (60–86400) |
enabled |
boolean | on |
postmaster | Master switch: off disables crypto for benchmarking overhead. Fixed at server startup |
max_encrypted_relations |
integer | 1024 |
postmaster | Max per-table DEK entries in shmem (64–65536). Requires restart — controls shared-memory allocation. |
crypto_provider |
string | '' |
postmaster | OpenSSL 3.x provider name (qatprovider, fips; empty = built-in dispatch). Requires restart. |
All variables are declared extern in src/include/pg_vault_tde_guc.h
and included by any translation unit that needs them (tam.c, kms.c).
Extension Initialization
_PG_init Sequence
_PG_init()
├── DefineCustomStringVariable("pg_vault_tde.vault_url", ...) [PGC_SUSET]
├── DefineCustomStringVariable("pg_vault_tde.vault_token", ...) [PGC_SUSET, GUC_NOT_IN_SAMPLE]
├── ... ~20 more GUC parameters (all PGC_SUSET except max_encrypted_relations/crypto_provider/enabled) ...
├── install shmem_request_hook → pg_vault_tde_shmem_request()
│ ├── pg_vault_tde_kms_shmem_request()
│ │ └── RequestAddinShmemSpace(sizeof(pg_vault_tde_kms_cache))
│ └── pg_vault_tde_catalog_shmem_request()
│ ├── RequestAddinShmemSpace(tde_rel_dek_cache_size(capacity))
│ └── RequestNamedLWLockTranche("TdeRelDekMap", 1)
├── install shmem_startup_hook → pg_vault_tde_shmem_startup()
│ ├── pg_vault_tde_kms_shmem_init() (Vault-token cache)
│ │ ├── ShmemInitStruct("pg_vault_tde_kms_cache", ..., &found)
│ │ └── if !found: LWLockNewTrancheId() + LWLockInitialize()
│ │ ← dynamic tranche; requires shmem to be up!
│ ├── pg_vault_tde_catalog_shmem_init() (per-relation DEK cache)
│ │ ├── ShmemInitHash("TdeRelDekMap", capacity, capacity, ...)
│ │ └── rel_dek_lock = &GetNamedLWLockTranche("TdeRelDekMap")[0].lock
│ └── tde_shmem_started = true; tde_active_kms_provider->init()
├── pg_vault_tde_tam_init()
│ └── memcpy(&tde_methods, GetHeapamTableAmRoutine(), sizeof(TableAmRoutine))
│ + install 5 write + 8 read + 2 visibility/build + 2 rewrite AM wrappers
└── register on_proc_exit(tde_backend_cleanup)
├── tde_crypto_ctx_cleanup() ← EVP_CIPHER_CTX_free + OPENSSL_cleanse iv_batch
└── tde_iam_ctx_cleanup() ← EVP_CIPHER_CTX_free SIV enc/dec context
Critical: Why LWLockNewTrancheId cannot be called from _PG_init
In PostgreSQL 17+, LWLockNewTrancheId() acquires
WaitEventCustomCounterLock, a spinlock stored in shared memory.
Shared memory does not exist at _PG_init time, so calling
LWLockNewTrancheId() from _PG_init causes an immediate segfault. The two
shmem structs use two different (both correct) tranche strategies:
pg_vault_tde_kms_cache(dynamic tranche):RequestAddinShmemSpace()inshmem_request_hook;LWLockNewTrancheId()+LWLockInitialize()in theshmem_startup_hook!foundbranch.TdeRelDekMap(named tranche):RequestAddinShmemSpace()andRequestNamedLWLockTranche("TdeRelDekMap", 1)inshmem_request_hook(named-tranche requests are allowed there — onlyLWLockNewTrancheId()is not), thenGetNamedLWLockTranche("TdeRelDekMap")inshmem_startup_hook(the HTAB and its lock are created byShmemInitHash/ picked up from the tranche; noLWLockInitialize()needed for a named-tranche lock).
This is the correct pattern for all PG17+ / PG18 extensions that need a dynamic LWLock tranche.
Testing Strategy
Regression Tests
make ci-regress (driven by ci/scripts/run-regress.sh) runs four SQL files in
sequence, gated on the live extension version:
| File | Tests | Scope |
|---|---|---|
sql/regression_test.sql |
1–52 | v1.0–v1.4 baseline: crypto, TAM, TOAST, tde_btree |
sql/regression_test_v15.sql |
53–72 | v1.5: per-table DEK, online rotation, AAD |
sql/regression_test_v16.sql |
73–109 | v1.6: local wallet KMS |
sql/regression_test_v17.sql |
111–134 | v1.7: enc_ops indexes, partition trees, HOT/REINDEX, FK lifecycle, TidRangeScan |
Test 110 (WITH HOLD cursor spill) is permanently deferred. The full suite is therefore
134 tests. The table below details the v1.0–v1.4 baseline file:
| Range | Area |
|---|---|
| 1–11 | AES-256-GCM crypto primitives, DEK rotation, tamper detection |
| 12 | TAM INSERT + SELECT basic round-trip |
| 13 | On-disk plaintext absence (raw file scan) |
| 14 | TAM UPDATE (ctid preservation, tuple refetch, HOT chains) |
| 15 | DELETE (no crash, row unreachable) |
| 16 | All-NULL rows (user_len == 0 edge case) |
| 17 | Index scan (index_fetch_tuple + rd_tableam workaround) |
| 18 | COPY / bulk insert (multi_insert path) |
| 19 | Multi-column types (int, text, bool, numeric, timestamptz) |
| 20 | Key rotation isolation (DEK-A rows rejected after rotating to DEK-B) |
| 21 | ANALYZE (statistics computed on decrypted values) |
| 22 | SELECT FOR UPDATE (tuple_lock path) |
| 23 | BitmapHeapScan (scan_bitmap_next_tuple via forced bitmap scan) |
| 24 | TABLESAMPLE (scan_sample_next_tuple via SYSTEM(100)) |
| 25–48 | UPSERT, MERGE, TRUNCATE, REINDEX, ALTER, JOINs, CTEs, HW accel, Vault, logical decoding |
| 49 | Wire format v2 round-trip (version byte + generation counter) (v1.4) |
| 50 | tde_btree CREATE INDEX + equality index scan (v1.4) |
| 51 | health_check() kms_provider GUC coherence (v1.4) |
| 52 | tde_btree UNIQUE constraint (v1.4) |
Page Checksum Test (make ci-checksums)
Starts PostgreSQL with initdb -k (--data-checksums). Verifies that:
1. Page checksums are enabled (SHOW data_checksums = 'on').
2. Encrypted pages pass checksum validation (checksums cover encrypted bytes).
3. The relation file is physically non-zero (encryption actually ran).
TAP Tests (tap/)
| File | Coverage |
|---|---|
tap/01_load.t |
Extension load, AM registration, basic SQL round-trip |
tap/02_backup.t |
pg_basebackup |
Isolation Tests (isolation/dek_rotation.spec)
Verifies: 1. DEK rotation does not block concurrent read transactions. 2. New inserts after rotation use the new generation. 3. Reads that started before rotation complete without error (MVCC + local cache).
Anti-Patterns (DO NOT)
- DO NOT mix rows encrypted with different DEKs in the same table during sequential scan tests — the scan encounters wrong-DEK rows first and raises GCM authentication errors before reaching target rows. Use separate tables per DEK epoch in rotation tests.
- DO NOT assume
slot->tts_tidis valid afterExecFetchSlotHeapTuple(slot, false, ...)— it returns thetupdataworkspace with uninitialisedt_self. Read TID frombslot->base.tuple->t_selfwhile the buffer pin is held. - DO NOT call
pg_vault_tde_decode_sloton an already-decoded slot without the double-decode guard (bslot->buffer == InvalidBuffer).
Packaging
Pre-installation requirement: wallet base directory
Before the extension can initialise a local wallet, the base directory
/var/lib/pg_vault_tde/ must exist and be owned by the OS user that runs
PostgreSQL (typically postgres). The directory must not be inside
PGDATA — see Security Considerations.
The package installers (postinst / %pre) create it automatically. For bare source builds or container images, run once as root:
mkdir -p /var/lib/pg_vault_tde
chown postgres:postgres /var/lib/pg_vault_tde
chmod 0700 /var/lib/pg_vault_tde
pg_vault_tde_wallet_init() creates the per-database subdirectory
(/var/lib/pg_vault_tde/<db_oid>/) at runtime; it does not create
the base directory. If the base directory is missing the function fails
with an actionable error and hint.
DEB (Debian / Ubuntu)
# Build
bash packaging/build_deb.sh --no-sign
# Install (creates /var/lib/pg_vault_tde via postinst)
dpkg -i ../postgresql-18-pg-vault-tde_1.0-1_amd64.deb
# Verify
dpkg -l | grep pg-vault-tde
RPM (RHEL / Rocky / Fedora)
# Build
bash packaging/build_rpm.sh
# Install (creates /var/lib/pg_vault_tde via %pre scriptlet)
dnf install ~/rpmbuild/RPMS/x86_64/postgresql18-pg_vault_tde-1.0-1.*.rpm
# Verify
rpm -qi postgresql18-pg_vault_tde
Hardware Acceleration
There is a single build/package — no TDE_TARGET_ARCH variant. Hardware-
accelerated AES dispatch (AES-NI, VAES, ARM CE, SVE2) is provided by
OpenSSL’s EVP layer automatically at runtime on this one build; see
“Hardware acceleration” above and src/crypto/pg_vault_tde_hw_accel.c.
A previous version of this codebase shipped TDE_TARGET_ARCH-selected
compiler flags and matching -aesni/-vaes/-armce package variants, but
none of the corresponding preprocessor defines (TDE_HW_AES_NI,
TDE_HW_VAES, TDE_HW_ARM_CE, TDE_HW_ARM_SVE2) were ever referenced by
any #ifdef/#if defined in src/, so those variants never produced a
measurably faster .so — they were removed.
Version Matrix
| pg_vault_tde | PostgreSQL | OpenSSL | Status |
|---|---|---|---|
| 1.6.x | 17.x, 18.x | 3.x | ✅ Completed |
| 1.7.x | 17.x, 18.x | 3.x | 🔄 Current |
| 1.8.x | 17.x, 18.x, 19.x | 3.x | 📋 Planned |
Roadmap
See ROADMAP.md for the full release roadmap.
| Version | Theme | Status | Tests |
|---|---|---|---|
| v1.1 | KMS/Vault + Key Rotation + HW Accel | ✅ Completed | 41 |
| v1.2 | Logical Decoding | ✅ Completed | — |
| v1.3 | Vault KEK + multi_insert + BGW | ✅ Completed | 48 |
| v1.4 | CI/CD + tde_btree + Wire Format v2 | ✅ Completed | 52 |
| v1.5 | Per-Table DEK + Online Rotation + AAD | ✅ Completed | 72 |
| v1.6 | Local Wallet KMS (production-ready) | ✅ Completed | 109 |
| v1.7 | Per-database KMS + pg_restore_tde + PGC_SUSET + enc_ops indexes | 🔄 Current | 109 |
| v1.8 | KMIP + Column-Level + GIN/Hash/GiST/BRIN + HA + Dual-Control | 📋 Q2 2027 | ~130 |
Permanent Deferrals
| Gap | Reason |
|---|---|
| WAL encryption | Requires PG core hook (XLogInsert()) — not extension API |
| BRIN on encrypted columns | min/max of ciphertexts is meaningless |
| General GiST (range, geometric) | Penalty/picksplit requires ordering |
| Full-text phrase search | Positional ordering destroyed by AES-SIV |
Contributing
This extension follows PostgreSQL’s BSD-derived coding style and
pgindent formatting conventions. All contributions must:
- Pass
makewith-Wall -Wextraand zero warnings - Pass the full 109-test regression suite (
make ci-regress) - Pass the page checksum compatibility test (
make ci-checksums) - Use
palloc/pfreeexclusively (nevermalloc/free) - Use
ereport/elogexclusively (neverprintf/exit) - Be C99 conformant with
snake_casenaming - Clean IV + DEK memory with
OPENSSL_cleansebeforepfree - Not introduce circular module dependencies (TAM → Crypto → KMS; never reverse)
- Not use GPL/AGPL libraries (breaks PostgreSQL License compatibility)
See .github/copilot-instructions.md
and AGENTS.md for AI-assisted development guidelines and
subagent coordination protocol.