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
- 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
Architecture
pg_vault_tde is a PostgreSQL extension that provides Transparent Data Encryption at the Table Access Method layer. It operates entirely within the extension API; zero modifications to PostgreSQL core are required.
PostgreSQL Version Compatibility
| PG Major | Status | Notes |
|---|---|---|
| 17 | ✅ Supported | Baseline API set |
| 18 | ✅ Supported | scan_bitmap_next_tuple signature change — guarded with PG_VERSION_NUM |
| 19 | 🔜 Planned | Infrastructure ready; audit at release |
This is the canonical PostgreSQL support statement for the extension; it says nothing about operating systems or about which combinations are actually exercised in CI — see Support Matrix under Packaging for that.
Version-Specific API Differences
| API / Struct | PG 17 | PG 18 | Guard Macro |
|---|---|---|---|
scan_bitmap_next_tuple |
(scan, slot, recheck) |
(scan, slot, recheck, lossy, exact) |
PG_VERSION_NUM >= 180000 |
shmem_request_hook |
Available | Available | (none needed) |
GetHeapamTableAmRoutine() |
Returns const * |
Returns const * |
(none needed) |
tuplesort_begin_index_btree() assert on relam |
Asserts rd_rel->relam == BTREE_AM_OID |
No such assert | PG_VERSION_NUM < 180000 — pg_vault_tde_ambuild() temporarily impersonates BTREE_AM_OID on index->rd_rel->relam during the sort |
BuildSpeculativeIndexInfo() assert on ON CONFLICT |
Asserts against non-btree-looking relam for unique tde_btree indexes |
No such assert | PG_VERSION_NUM < 180000 — tde_executor_start_hook() swaps relam back to BTREE_AM_OID for the duration of the query, restored via a MemoryContextCallback |
Design Goals
| Goal | Achieved | Notes |
|---|---|---|
| Plug-and-play | ✅ | shared_preload_libraries + CREATE EXTENSION only |
| Zero core patches | ✅ | Pure extension API (tableam, indexam) |
| AES-256-GCM per-tuple | ✅ | Authenticated encryption; integrity verified on read |
| Hardware acceleration | ✅ | OpenSSL 3.x EVP dispatch → AES-NI / ARM Crypto |
| Key rotation | ✅ | Lazy generation-epoch detection; no scan needed |
| MVCC compatibility | ✅ | HeapTupleHeader stays plaintext |
| pg_dump / pg_restore | ✅ | Decrypts transparently at TAM scan layer |
| Page checksums | ✅ | Checksums cover encrypted bytes |
Component Map
PostgreSQL Core
└── Extension API
├── TAM: encrypted_heap src/tam/pg_vault_tde_tam.c
│ ├─ Write path (encrypt) tde_encrypt_heap_tuple()
│ └─ Read paths (decrypt) pg_vault_tde_decode_slot()
├── IAM: tde_btree src/iam/pg_vault_tde_iam.c
│ └─ AES-256-SIV key encrypt tde_iam_encrypt_key()
├── Crypto src/crypto/pg_vault_tde_crypto.c
│ ├─ tde_gcm_encrypt() AES-256-GCM via OpenSSL 3.x EVP
│ └─ tde_gcm_decrypt() Authenticated decryption
├── Per-relation DEK cache src/kms/pg_vault_tde_catalog.c
│ ├─ DEK cache (shmem HTAB) TdeRelDekMap (one shared LWLock)
│ └─ get DEK for a relation pg_vault_tde_kms_get_rel_dek(relid)
├── KMS provider vtable src/kms/pg_vault_tde_kms.c
│ └─ Vault provider (libcurl) vault_provider_{wrap,unwrap,rewrap}_dek()
├── Local wallet provider src/kms/pg_vault_tde_kms_local.c (PKCS#12)
├── DEK catalog (on-disk) src/kms/pg_vault_tde_catalog.c (wrapped_dek)
├── Rotation background worker src/kms/pg_vault_tde_rotation_bgw.c
├── HW acceleration src/crypto/pg_vault_tde_hw_accel.c
├── Logical decoding plugin src/logical/pg_vault_tde_pgoutput.c
├── Backup src/backup/pg_vault_tde_backup.c
│ (+ pg_dump_tde.c / pg_restore_tde.c)
└── Entry point src/pg_vault_tde.c (_PG_init)
Key Lifecycle
How each provider is configured — GUCs, credentials, per-database settings — is covered in Configure Key Access for Vault / OpenBao, the local wallet and PKCS#11. This chapter describes what happens to key material once a provider is in place; the PKCS#11 provider gets its own chapter below because it is the only one with meaningful implementation surface of its own.
Vault / OpenBao (KEK owner) ──or── Local wallet (PKCS#12, KEK-on-disk)
│
│ unwrap wrapped_dek via active provider vtable (synchronous;
│ libcurl HTTP(S) for Vault, AES-256-WRAP for local)
▼
pg_vault_tde_kms_get_rel_dek(relid) [src/kms/pg_vault_tde_catalog.c]
│ fast path: LW_SHARED hash_search of TdeRelDekMap (cache hit)
│ slow path: read pg_vault_tde_catalog.wrapped_dek → provider
│ unwrap → hash_search(HASH_ENTER) under LW_EXCLUSIVE
▼
TdeRelDekMap (shmem HTAB) [one entry per (dbid, relid); single
│ shared LWLock; generation + prev_dek]
│ DEK (32 bytes) copied into a stack buffer on every call; the crypto
│ layer caches the AES key schedule keyed by (relid, generation) — no
│ dbid there, those statics are per-backend and a backend is bound to
│ one database
▼
tde_gcm_encrypt() / tde_gcm_decrypt() [src/crypto/pg_vault_tde_crypto.c]
│
▼
Disk: [HeapTupleHeader | attributes, value bytes encrypted | IV(12) | GCM-TAG(16) | VER(1) | GEN(8)]
Shared Memory Layout
Since v1.7 the cache is a shared-memory hash table (HTAB), not a fixed
array scanned linearly. Each entry is one TdeRelDekMap, keyed by
(dbid, relid):
/*
* Cache key. relid is unique only WITHIN a database — never across
* databases, never cluster-wide — while this HTAB is one segment read by the
* backends of every database. CREATE DATABASE physically copies the
* template's directory, so a clone hands out pg_class OIDs identical to its
* template's: colliding relids are normal, not a corner case.
*/
typedef struct TdeRelDekMapKey {
Oid dbid; /* always MyDatabaseId */
Oid relid; /* effective relid (TOAST → parent, etc.) */
} TdeRelDekMapKey;
/* Per-relation DEK entry — value type of the TdeRelDekMap HTAB (v1.5+) */
typedef struct TdeRelDekMap {
TdeRelDekMapKey key; /* hash key */
char dek[TDE_DEK_LEN]; /* current AES-256 DEK, 32 bytes */
char prev_dek[TDE_DEK_LEN]; /* previous DEK (valid during rotation) */
uint64 generation; /* rotation epoch for this relation */
bool dek_valid; /* true iff dek[] holds a live key */
bool prev_dek_valid; /* true iff prev_dek[] is populated */
} TdeRelDekMap;
dbidis not a parameter of any public function inpg_vault_tde_catalog.h.tde_rel_dek_key()fills it fromMyDatabaseIdwhen it builds the key, because every path that reaches the cache runs connected to the database owning the relation: a regular backend, the rotation BGW afterBackgroundWorkerInitializeConnectionByOid(), or a walsender during logical decoding. One derivation point instead of fourteen call sites that could each pass the wrong value.- The on-disk catalog needs no dbid.
pg_vault_tde_catalogis an ordinary table created byCREATE EXTENSIONin the extension’s schema, so it exists once per database and itsrelidprimary key is unambiguous there; every access (tde_catalog_read_row,rewrap_all,read_all_wrapped,upsert_row) goes throughtable_open()in the current backend’s database. The local wallet is per-database for the same reason, at/var/lib/pg_vault_tde/<db_oid>/wallet.p12. Shared memory was the only place where per-database namespaces met. - A relid-only key does not silently return wrong plaintext: the GCM AAD
binds
MyDatabaseId(tde_compute_aad()), so the victim database getsAES-256-GCM authentication FAILEDon intact data. A read outage, not a corruption — covered bytap/21_cache_key_cross_db.t. 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("pg_vault_tde_rel_dek_map", capacity, capacity, &info, HASH_ELEM | HASH_BLOBS)wherecapacity = pg_vault_tde.max_encrypted_relations. The segment is sized withhash_estimate_size(capacity, sizeof(TdeRelDekMap)).HASH_BLOBSmeans the key is hashed as raw bytes, sotde_rel_dek_key()zeroes the struct before filling it — padding must not leak into the hash. Capacity is cluster-wide: with encrypted tables in several databases, budget for the sum. - There is no per-entry lock. A single
LWLock(file-scoperel_dek_lock) from a named tranche guards the whole table:RequestNamedLWLockTranche("pg_vault_tde_rel_dek_map", 1)in theshmem_request_hook, then&GetNamedLWLockTranche("pg_vault_tde_rel_dek_map")[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 | Always selects encrypted_heap as the TOAST AM (since 1.7.2 regardless of pg_vault_tde.toast_encryption, PSQLE-223), so the encrypted chunks are decrypted through the same index_fetch_tuple/scan_getnextslot hooks as the main table. |
pg_vault_tde_decode_slot
This function is the core of the read path. It:
- 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)
{
if (!pg_vault_tde_toast_encryption)
ereport(WARNING, ... "has no effect" ...); /* PSQLE-223 */
encheap_oid = get_table_am_oid("encrypted_heap", true);
if (OidIsValid(encheap_oid))
return encheap_oid;
return HEAP_TABLE_AM_OID; /* only if the AM is missing */
}
TOAST tables are created with the encrypted_heap AM so that every TOAST chunk is
encrypted individually using the parent relation’s DEK. On PG 18 the heap_getnext
identity assertion inside the TOAST index build would reject encrypted_heap;
the rd_tableam impersonation workaround is applied during
index_build_range_scan to satisfy this assertion.
pg_vault_tde.toast_encryption has had no effect since 1.7.2 (PSQLE-223). The TOAST
pipeline encrypts every chunk it writes whatever the TOAST table’s AM, so off never
gave plaintext TOAST: up to 1.7.1 it gave a heap TOAST table of encrypted chunks, read
back undecrypted, and every out-of-line value of the table failed. Setting it off now
only raises a WARNING; the parameter is removed in 1.8. A table left unreadable by
1.7.1 is repaired by giving its TOAST table the encrypted_heap access method — see
README.md › Upgrading to 1.7.2 › Tables created with toast_encryption = off.
PG18-Specific API Notes
scan_bitmap_next_tuple
The signature changed in PG18:
/* 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 5 is the format written today; version 4 is still read, so a table
written before the upgrade keeps working. The legacy v1/v2/v3 formats were
removed. (The byte 0x02 still appears only in the pg_dump_tde backup
block format — a separate code path, see Backup.)
v5 is structure preserving: every attribute stays at its own offset with its own length, and only the bytes of the VALUES are replaced by ciphertext.
+--------------------------------+----------+----------+-------+----------+
| ATTRIBUTES, values encrypted | IV | GCM TAG | VER | GEN |
| D bytes (= plaintext data len) | 12 bytes | 16 bytes | 1 byte| 8 bytes |
+--------------------------------+----------+----------+-------+----------+
random 0x05 uint64 LE
Total overhead: TDE_V4_OVERHEAD = 37 bytes
(TDE_GCM_IV_LEN=12 + TDE_GCM_TAG_LEN=16 + 1 version byte + TDE_V4_GEN_LEN=8),
identical to v4 — a v5 tuple is exactly as long as the v4 tuple for the same row.
v4 replaced the whole user-data region with one opaque blob
([IV | CIPHERTEXT | TAG | VER | GEN]) while the tuple header, copied verbatim,
still advertised natts attributes laid out per the tuple descriptor. Core code
that deforms an on-disk tuple then walked ciphertext as if it were a tuple —
and heap_update() does exactly that, reading the indexed attributes straight
off the page to decide HOT and which indexes to maintain. Past the first
variable-length column the attribute offset is not cached, so the walk read a
varlena length header out of ciphertext, got a length of up to 1 GB and left the
page: SIGSEGV (PSQLE-165). Any index on such a column was enough, tde_btree
included — the trigger is the index attribute bitmap, not the access method.
What v5 gives up in exchange: the structural bytes stay in clear, because they are what makes the walk possible. Concretely the exact byte length of every variable-length column is visible in the heap file, along with whether the value is compressed or held out of line. Fixed-length columns leak nothing (their length is in the catalog), and the row length and null bitmap were already visible in v4. Attribute values themselves are never in clear — regression test 143 reads the raw heap file and asserts it.
The AEAD is unchanged: the value bytes are gathered into one buffer, handed to
tde_gcm_encrypt() and scattered back, so ciphertext, tag and AAD are
bit-identical to what v4 produced for the same input.
Upgrading an existing table. v4 tuples are read transparently, but they keep
their old layout: UPDATE on a v4 row with an index behind a variable-length
column still crashes, because nothing can make that layout walkable after the
fact. VACUUM FULL (or CLUSTER) rewrites every row through the TAM and
migrates the table to v5.
v4 binds each tuple to its location by passing
[MyDatabaseId(4) | relid(4) | generation(8)] (little-endian, TDE_V4_AAD_LEN = 16
bytes) as GCM Additional Authenticated Data — zero wire overhead; prevents
cross-table ciphertext smuggling. The AAD is reconstructed on decrypt from the
stored generation in the wire trailer (not the current generation), so old-generation
rows still authenticate during the rotation window.
When user_len == 0 (all-NULL tuple, or tuple with only system columns),
tde_gcm_encrypt() still produces a full TDE_V4_OVERHEAD-byte (37) block. This
exercises the GCM tag path on zero data; the decrypt path handles it symmetrically.
Test 16 covers this edge case.
Memory Security
- DEK copies in per-backend memory are
OPENSSL_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 int iv_batch_pid = 0; /* the process that filled it */
static void tde_next_iv(unsigned char *iv_out)
{
if (iv_batch_pos >= TDE_IV_BATCH_SIZE || iv_batch_pid != MyProcPid)
{
if (!pg_strong_random(iv_batch, TDE_IV_BATCH_BYTES))
ereport(ERROR, (errmsg("[CRYPTO] Failed to generate IV batch")));
iv_batch_pos = 0;
iv_batch_pid = MyProcPid;
}
memcpy(iv_out, iv_batch + iv_batch_pos * TDE_GCM_IV_LEN, TDE_GCM_IV_LEN);
iv_batch_pos++;
}
The buffer is wiped with OPENSSL_cleanse() in the backend-exit cleanup.
This amortises the syscall cost across 256 tuples.
A batch belongs to the process that filled it. A fork() copies it, and two
processes serving the same IVs under one DEK would void GCM for those tuples. No
PostgreSQL process forks after drawing an IV — the postmaster encrypts nothing — so
every process starts empty; the pid check keeps it so if that changes, and an Assert
on assertion-enabled builds compares MyProcPid with getpid() (PSQLE-178).
tap/48_iv_uniqueness.t reads every IV off the raw pages and checks that none repeats
under one DEK generation.
Limit per key. With random 96-bit IVs, NIST SP 800-38D allows at most 232
encryptions under one key — here one DEK generation: every tuple written, every row
rewritten, every TOAST chunk. rotate_online() starts a new generation; the README
(“Routine administration”) says how to estimate where a table stands.
Benchmark
Run the included benchmark against a live container:
bash 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 per-relation via pg_vault_tde_rotate_online(relname, batch_size).
The rotation worker runs one transaction:
1. Takes ShareRowExclusiveLock on the heap — writers and a second rotation wait,
SELECT continues — and only then its snapshot, so rows committed by the writers it
waited for are re-encrypted too.
2. pg_vault_tde_catalog_zero_rel_dek() moves the current DEK to prev_dek, wipes
dek[32], sets dek_valid = false and rotating = true, and keeps the outgoing
key in the worker’s own memory. generation stays the outgoing key’s.
3. pg_vault_tde_catalog_update_rel_dek() writes DEK N+1 to the catalog row and hands
it to the worker’s memory, never to the shared cache.
4. pg_vault_tde_reencrypt_table() rewrites every row with DEK N+1. Out-of-line values
are fetched back from the TOAST relation first (still compressed), so the toaster
stores them again under DEK N+1 and deletes the old chunks — reused as they were,
they kept DEK N, which the catalog no longer holds (PSQLE-189).
5. A transaction callback moves the cache entry to DEK N+1 at commit (before the locks
are released) or back to DEK N at abort, and clears rotating.
While rotating is set nobody installs a current key: every other session encrypts and
decrypts with the outgoing DEK, which is what its snapshot of the catalog shows. Every
ciphertext carries its generation, so decryption asks for the key of that generation
(pg_vault_tde_kms_get_rel_dek_for_gen()), and encryption reads DEK and generation in
one call (pg_vault_tde_kms_get_rel_dek_gen()).
- Bounded staleness: At most one LWLock pair per encrypt/decrypt call.
- No signals: Generation mismatch is detected lazily; no SIGUSR1/SIGHUP needed.
- Fork safety: fork() after
shmem_startup_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 — the planner never uses tde_btree for ranges, ORDER BY, min/max or merge joins (sequential scan instead); a forced range is an error |
| Use case | Equality predicates only (=, IN, = ANY, ON CONFLICT) |
| Column support | Varlena bytea/text (tde_*_ops; since 1.7.2 no index creation path accepts numeric or a nondeterministic collation) and fixed-size int4/int8/uuid/date/timestamptz (tde_*_enc_ops, default since v1.7). All index keys are AES-256-SIV encrypted. |
AES-SIV is chosen over AES-GCM for index entries because: - It produces a deterministic ciphertext (required for B-Tree comparisons). - It provides authentication (misuse-resistant — no IV to manage). - It prevents key reuse attacks that would be possible with AES-ECB.
Implementation
The implementation uses the OpenSSL 3.x provider API:
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. A range key (sk_strategy != 3) is an error:
AES-SIV does not preserve ordering, so walking one against the tree would
return wrong rows. The planner never builds such a scan on its own — a
get_relation_info_hook removes the index’s sort order and
pg_vault_tde_amcostestimate() prices non-equality paths out — so only a
forced plan reaches that error. amsearcharray is off: the executor expands
IN (…) / = ANY (…) into one scalar lookup per element.
Operator Class
-- 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 classes (tde_int4_ops,tde_int8_ops,tde_uuid_ops,tde_date_ops,tde_timestamptz_ops) store their keys in plaintext and are retained only for indexes already built on them: since 1.7.2 a new index cannot use them unlesspg_vault_tde.allow_plaintext_index = on, and 1.8 removes them.
Index-Only Scans
Index-only scans are not supported on tde_btree indexes by design. PostgreSQL
index-only scans return column values directly from the index pages without visiting
the heap. Since tde_btree stores AES-256-SIV ciphertexts as index keys, returning
those values directly would expose raw ciphertext to the client with no decryption.
All decryption happens in the TAM layer (decode_slot) when the heap tuple is
fetched. The planner is prevented from choosing an index-only scan path on
tde_btree indexes; it always fetches the tuple from the encrypted_heap table.
Range scans, ORDER BY and min()/max() are never served from a tde_btree
index — AES-256-SIV does not preserve ordering regardless of column type — and run
as sequential scans instead.
Usage Example
-- Create an encrypted table
CREATE TABLE employees (
id int4,
username text,
salary numeric
) USING encrypted_heap;
-- Create tde_btree indexes on multiple column types. The opclass is optional:
-- the encrypted-key classes are the DEFAULT for each type since v1.7, so
-- `USING tde_btree (id)` picks tde_int4_enc_ops automatically.
CREATE INDEX employees_id_idx ON employees USING tde_btree (id tde_int4_enc_ops);
CREATE INDEX employees_username_idx ON employees USING tde_btree (username tde_text_ops);
-- Equality lookups use the encrypted index
INSERT INTO employees VALUES (1, 'alice', 90000);
INSERT INTO employees VALUES (2, 'bob', 85000);
SELECT salary FROM employees WHERE id = 1; -- uses index
SELECT id FROM employees WHERE username = 'alice'; -- uses index
-- Range predicates run as a sequential scan: tde_btree answers equality only
SELECT * FROM employees WHERE id > 1; -- seq scan, not index scan
-- Index-only scans are not supported and never chosen by the planner;
-- the heap tuple is always fetched to decrypt column values.
Logical Decoding and Replication
pg_vault_tde ships a logical decoding output plugin (pg_vault_tde_pgoutput)
so that encrypted_heap tables can be published to logical replication
subscribers in plaintext, even though their on-disk tuples — and their WAL —
are ciphertext.
Why a plugin is needed
The TAM decrypt-on-read callbacks run in the query executor, not in the WAL sender. A logical decoder reads raw WAL records whose tuple bodies are ciphertext, so without intervention a subscriber receives encrypted garbage.
Non-TOAST tables — pgoutput wrapper
_PG_output_plugin_init loads the built-in pgoutput via
load_external_function(), lets it populate every callback, then overrides the
change callbacks with thin wrappers that decrypt the tuple in place (via
tde_decrypt_heap_tuple) before delegating back to pgoutput for the actual
serialization. Because the emitted wire format is exactly the pgoutput
protocol, this works with pg_recvlogical and with a native CREATE
SUBSCRIPTION pointed at a slot created with this plugin. (Same wrapping
technique as Citus’s CDC decoder.)
TOAST columns — custom WAL resource manager
Externally-TOASTed columns need more than in-place decryption: the core reorder
buffer reassembles a TOAST value by heap_deform_tuple()-ing the chunks and the
main tuple before any output-plugin callback runs, and on an encrypted tuple
that crashes (got sequence entry … for toast chunk). There is no extension
hook earlier than that point.
The lever that does exist is a custom WAL resource manager, gated by the GUC
pg_vault_tde.toast_custom_rmgr (PGC_POSTMASTER, default off; requires
pg_vault_tde in shared_preload_libraries). When enabled:
- Write path —
tde_toast_wal_insert()(a faithful clone ofheap_insert) logs encrypted TOAST chunks underTDE_RMGR_ID(161, registered for pg_vault_tde on the PostgreSQL Custom WAL Resource Managers wiki) instead 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.
Server configuration
PostgreSQL 17.11 / 18.x — and the matching minors of the older back branches —
only load a library as a logical decoding output plugin if it is listed in the
output_plugin_libraries GUC (default pgoutput, test_decoding). On those
versions the publisher must be told to accept this plugin, otherwise slot
creation fails with:
ERROR: library "pg_vault_tde" may not be used as an output plugin
HINT: ... add it to "output_plugin_libraries" and reload the server configuration.
# postgresql.conf on the publisher (PGC_SUSET — a reload is enough)
output_plugin_libraries = 'pgoutput, pg_vault_tde'
It must be set in the server configuration, not in a session: the process that
loads the plugin is the walsender, not the client backend. Earlier minors have
no such GUC — and an unrecognised parameter in postgresql.conf is fatal at
startup — so add the line only where pg_settings reports it.
Requirements and supported operations
| Operation | Requirement |
|---|---|
| INSERT (inline, TOAST, bursts) | toast_custom_rmgr = on for TOAST columns |
| Initial table sync (COPY) | works via the TAM read path (decrypt-on-read) |
| UPDATE / DELETE | REPLICA IDENTITY FULL + a primary key |
REPLICA IDENTITY FULL is mandatory for UPDATE/DELETE: with DEFAULT the core
derives the replica identity by reading the encrypted old tuple as if it
were the key, producing a constant garbage key — the subscriber then silently
targets the wrong row. Tables without a primary key are likewise unsupported for
UPDATE/DELETE (no key to match on). These are documented limitations, not bugs:
they follow from the tuple being an opaque ciphertext blob to the core.
Structural limitations
heap_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. - Slots behind a key rotation — a rotation is decoded as one UPDATE per row,
and WAL written under the previous DEK generation decodes only while that key is
still in shared memory (the catalog keeps the current one only). A slot that has
not decoded it when the publisher restarts, or when the same table is rotated
again, fails with
pg_vault_tde: decryption failedat the same LSN on every retry — every slot of the database, since the plugin decrypts before pgoutput filters by publication. Let slots confirm past a rotation first; persisting previous keys is the 1.8 key ring. - Aborted-transaction capture — a TOAST-writing transaction that reaches a full snapshot and then aborts without being streamed leaves its captured chunks in memory until the decoding process exits (there is no output-plugin hook for non-streamed aborts; it is a slow, per-abort leak, not per-row).
This ciphertext-as-opaque-blob conflict — every place the core reads a single column (e.g. replica identity) sees ciphertext — is the motivation for the column-level encryption alternative on the v1.8 roadmap.
PKCS#11 / HSM Provider
The pkcs11 KMS provider (v1.7, src/kms/pg_vault_tde_kms_pkcs11.c) keeps
the KEK inside a hardware security module. It talks the Cryptoki API
directly: the vendor’s PKCS#11 module (pg_vault_tde.pkcs11_library) is
dlopen()ed at runtime and every DEK is wrapped/unwrapped with
C_WrapKey/C_UnwrapKey using CKM_AES_KEY_WRAP (RFC 3394, the same
algorithm the local wallet provider uses in software), with a runtime
fallback to CKM_AES_KEY_WRAP_PAD. No OpenSSL involvement and no
build/runtime dependency: the OASIS interface headers are vendored under
src/include/pkcs11/ (include them only through
src/include/pg_vault_tde_cryptoki.h).
Key Hierarchy and Threat Model
HSM token (user PIN via env var)
└── KEK: AES-256, CKO_SECRET_KEY, CKA_SENSITIVE, CKA_EXTRACTABLE=FALSE
└── C_WrapKey (CKM_AES_KEY_WRAP) → per-table DEK (40-byte blob
│ in pg_vault_tde_catalog)
└── encrypts tuple data (AES-256-GCM, in-process)
Only the KEK is confined to the HSM: tuple crypto runs in-process, so
the plaintext DEK necessarily transits backend memory (stack buffers,
OPENSSL_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. pg_vault_tde.toast_encryption has no effect since 1.7.2 (PSQLE-223). |
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 | tde_btree answers equality only — ranges, ORDER BY, min/max run as sequential scans; numeric and nondeterministic collations refused (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 decides HOT by comparing the indexed columns' on-disk bytes between old and new tuple. A changed indexed value could re-encrypt to the same bytes (once in 256L for L bytes), so pg_vault_tde_tuple_update() re-encrypts a changed indexed column under a fresh IV (PSQLE-219); heap_update then always sees it as modified and skips HOT, keeping the index coherent. See the section below. |
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 |
| 11 | Three index paths store plaintext keys past the allow_plaintext_index guard, with no check and no warning — not supported in 1.7.2: an EXCLUDE constraint on a native access method (it is a constraint, so it skips the CREATE INDEX guard); a native index cloned onto an encrypted partition (PARTITION OF/ATTACH PARTITION, created internally with is_internal); a native index carried over by ALTER TABLE … SET ACCESS METHOD encrypted_heap. Do not use them on encrypted tables; index with tde_btree, and create native indexes before converting a table or attaching a partition. INCLUDE columns on a tde_btree index had the same effect and are rejected outright since v1.7.2. See README.md § Limitations item 11. |
v1.8 |
HOT updates are disabled by design
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 between the old and the new tuple image, on disk. On an
encrypted_heap table both images are encrypted, and every version of a row is
encrypted under a fresh random GCM IV, so an attribute is byte-stable across an
update only by chance — once in 256L for a value of L bytes. When the chance hits a
value that changed, pg_vault_tde_tuple_update() encrypts the row again under
another IV (PSQLE-219), so heap_update sees every changed indexed column as modified
and skips the HOT path. The constant [VERSION | GENERATION] bytes sit at the end of the
region, outside every attribute, so they cannot create a byte-stable window.
Historical note (v3 bug, fixed in v4). The 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; the workarounds v3 required (REINDEX, or arranging the indexed column past the first 9 bytes) are no longer needed.Historical note (v4 bug, fixed in v5 — PSQLE-165). v4 made the comparison read a region that was not laid out as a tuple at all, so past the first variable-length column
heap_updatewalked ciphertext looking for attribute boundaries. Usually that left the page and the backend died; when it stayed on the page it compared garbage, and an updated indexed column could come out unchanged — the v3 failure mode again, by a different route. v5 keeps the attribute layout intact, so the comparison reads real per-attribute ciphertext.Short values, fixed in 1.7.2 (PSQLE-219). Real per-attribute ciphertext is short when the value is: a
boolor a one-character text repeated its old ciphertext on oneUPDATEin 256, and when its value had changed thatUPDATEwent HOT — the index kept the old key, lookups missed the row and UNIQUE let a duplicate in. v4 had it too for short fixed-length columns, read at a fixed offset inside its random blob. Hence the re-encryption above;tap/49_hot_update_short_indexed.tcovers it.
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.toast_encryption = offno longer restores the v1.0 behaviour: it has no effect since 1.7.2 (PSQLE-223).Row re-encryption after rotation (ticket #2) — ✅ Resolved
pg_vault_tde_rotate_online(relname, batch_size)promotes the current DEK toprev_dekand moves the relation to the next generation; old-generation rows stay readable viaprev_dek(see Generation-Epoch Rotation).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.
What the authentication tag does not cover
The GCM tag of a tuple authenticates the bytes of its attribute values,
concatenated in attribute order, and an AAD of [MyDatabaseId | relid | generation].
A ciphertext therefore does not verify in another database, another relation, or
under another DEK generation: moved there, it is refused. Damage to the value bytes
is refused too. The tag does not bind:
- the tuple’s position — its block and line pointer. heapam chooses where a tuple goes after it has been formed, ciphertext included, so the position cannot be part of the AAD. Within one relation and one generation, a tuple image written back to another place, or an older image of the same table, still verifies.
- the tuple header —
xmin,xmax, the infomask. Core rewrites them (hint bits,xmax, freezing) without the key. Damage there can make a row version visible or invisible (tap/20_ondisk_fuzz.tcounts that outcome apart). - the layout of the values — the null bitmap and the length headers of variable-length attributes, which v5 keeps in clear (see Wire Format per Encrypted Region). They say where one value ends and the next begins, and they are not part of the tag: whoever can write the data files can change how a row’s bytes are divided among its variable-length attributes without failing it. No plaintext byte can be changed or added that way. Authenticating the layout is a change of tuple format, planned for 1.8 (PSQLE-218).
Detecting a replayed or moved tuple needs integrity over pages or relations, which
an extension cannot add; data checksums detect accidental damage only. All three are
outside the threat model: the attacker it defends against reads files, and does not
write to the data directory (see doc/SECURITY-REVIEW.md).
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 │ attrs (values enc.) │IV│TAG│V│G│ │
│ │ (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 — shown only to a superuser (others read ********), not in pg_settings (v1.7.2, PSQLE-224) |
vault_role_id |
string | '' |
suset | AppRole role_id UUID — shown only to a superuser (others read ********), not in pg_settings (v1.7.2, PSQLE-224) |
vault_secret_id |
string | '' |
suset | AppRole secret_id — shown only to a superuser (others read ********), not in pg_settings (v1.7.2, PSQLE-224) |
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'). Cluster-wide value = one KEK for every database; rotating it is then destructive (see Shared Memory Layout) |
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 — shown only to a superuser (others read ********), not in pg_settings (v1.7.2, PSQLE-224) |
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 | No effect since 1.7.2 (PSQLE-223): the TOAST table of an encrypted_heap table is always encrypted_heap and its chunks always encrypted with the parent relation’s DEK; setting it off only raises a WARNING when a TOAST table is created. Removed in 1.8 |
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), cluster-wide — entries are keyed by (dbid, relid), so budget for the sum across all databases. Enforced since 1.7.2 — before that the cache silently grew past it (ShmemInitHash’s size is not a cap), so count your encrypted relations across all databases before upgrading. ~112 bytes per relation, reserved at startup. Max 1048576. Requires restart. |
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_SUPERUSER_ONLY | GUC_NOT_IN_SAMPLE | GUC_NO_SHOW_ALL, show hook]
├── ... ~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("pg_vault_tde_rel_dek_map", 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("pg_vault_tde_rel_dek_map", capacity, capacity, ...)
│ │ └── rel_dek_lock = &GetNamedLWLockTranche("pg_vault_tde_rel_dek_map")[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("pg_vault_tde_rel_dek_map", 1)inshmem_request_hook(named-tranche requests are allowed there — onlyLWLockNewTrancheId()is not), thenGetNamedLWLockTranche("pg_vault_tde_rel_dek_map")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
Standalone Smoke Test
make install
make check-standalone
make check-standalone drives pg_regress directly: it initialises a
throwaway cluster under tmp_check/ on a free port, appends
test/regress.conf to its configuration, runs the pg_vault_tde_init test and
removes the cluster. No existing installation is started or modified, no KMS
service is contacted, and nothing is edited by hand.
Two constraints explain its shape. PGXS declines make check for out-of-tree
extensions, so the target cannot simply defer to the standard rule; and plain
make installcheck fails against a stock cluster, because the extension
registers its Table Access Method and requests shared memory from _PG_init
and therefore has to be preloaded. test/regress.conf carries exactly that one
setting — deliberately not a copy of the CI configuration.
It verifies that the extension builds, installs and loads. It does not exercise key management: that is the job of the suites below and of the providers listed under KMS Provider Coverage.
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–140, 154–164 | v1.7: enc_ops indexes, partition trees, HOT/REINDEX, FK lifecycle, TidRangeScan, on-disk tuple layout |
sql/regression_test_errorpath.sql |
141–153 | error paths (make ci-errorpath) |
Test numbers are one sequence shared by every suite, which is why 141–153 are missing
from the v1.7 file rather than being a gap. The real gaps are 5–11 and 49 (no
longer exist), 80 (removed in v1.7) and 110 (WITH HOLD cursor spill, disabled: a
permanent limitation), so make ci-regress runs 141 tests. The table below details the v1.0–v1.4 baseline file:
| Range | Area |
|---|---|
| 1–4 | Extension, access methods and SQL functions registered; wallet unlock |
| 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 |
| 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/)
49 files, run together by make ci-tap (which also starts the Vault container
the Vault-dependent files need; they skip_all when VAULT_ADDR is unset).
tap/43_soak.t skips unless PG_VAULT_TDE_SOAK=1; make ci-soak runs it alone.
| File | Coverage |
|---|---|
tap/01_load.t |
Extension load, AM registration, basic SQL round-trip |
tap/02_backup_local.t |
pg_dump_tde / pg_restore_tde round-trip, local wallet KMS |
tap/03_backup_vault.t |
Same round-trip against a real Vault Transit backend |
tap/04_backup_format.t |
Binary dump format, multi-object dump/restore |
tap/05_backup_data_variety.t |
TOAST, Unicode, bytea, bulk rows, sequences |
tap/06_backup_corruption.t |
Corrupted ciphertext, truncation, bad magic, IV randomness |
tap/07_backup_cli.t |
CLI argument validation of the backup wrappers |
tap/08_backup_security.t |
No plaintext leakage, IV uniqueness, opaque output |
tap/09_backup_wrong_key.t |
Wrong passphrase and missing-wallet scenarios |
tap/10_backup_cross_db.t |
Restore into a database other than the source |
tap/11_multi_kms_cluster.t |
Different KMS providers per database in one cluster |
tap/12_logical_repl_toast.t |
Logical replication of encrypted_heap TOAST values |
tap/13_index_concurrently.t |
CREATE INDEX CONCURRENTLY / REINDEX CONCURRENTLY |
tap/14_seal_keys.t |
Physical-backup key sealing bundle round-trip |
tap/15_basebackup_tde.t |
pg_basebackup_tde wrapper, multi-database bundles |
tap/16_pkcs11.t |
pkcs11 provider against a throwaway SoftHSM2 token |
tap/17_index_constraints.t |
Index AM whitelist, PRIMARY KEY / UNIQUE behaviour |
tap/18_guc_order_independence.t |
KMS GUCs are order- and scope-independent (see below) |
tap/20_ondisk_fuzz.t |
Arbitrary on-disk bit flips must yield correct data or a refusal, never a forged value (checksums disabled so the GCM tag is the line under test) |
tap/19_crash_recovery_rmgr.t |
Encrypted TOAST chunks survive WAL replay after an unclean shutdown — the only test that executes the custom resource manager’s rm_redo (see below) |
tap/21_cache_key_cross_db.t |
Two databases holding the same relid (a CREATE DATABASE ... TEMPLATE clone) with different DEKs must not share a shmem cache entry |
tap/22_rotate_cold_cache.t |
pg_vault_tde_rotate_online() must preserve the outgoing DEK when the shmem entry is cold (after a restart, or a wallet lock/unlock) |
tap/23_rotation_generation_drift.t |
An aborted rotation must not leave the shmem cache a generation ahead of the catalog (fault injection: the catalog row is removed mid-rotation) |
tap/24_shared_wallet_warning.t |
KEK rotation warns when the wallet is not this database’s own file, and stays quiet on the per-database default |
tap/25_cache_full_degrades.t |
max_encrypted_relations is honoured, and relations past it keep reading and writing |
tap/26_preload_keys.t |
The startup warm-up loads a database’s DEKs when it asks, skips the databases that did not, keeps their keys apart, and honours preload_max_failures |
tap/27_preload_providers.t |
The warm-up works with the KEK outside the server: Vault/OpenBao and PKCS#11 (each half skips when its backend is absent) |
tap/28_dml_memory_scaling.t |
Per-row memory is released on every DML path: a second session samples pg_log_backend_memory_contexts() mid-statement and compares encrypted_heap with a plain heap under the same workload — the only stage that sees a lifetime bug |
tap/29_rotate_online_concurrent_access.t |
A table read or written while pg_vault_tde_rotate_online() runs stays readable after the rotation, after a second rotation and after a restart; a row lock on pg_vault_tde_catalog holds the worker inside the window (PSQLE-184). Runs once per available provider: local, Vault (VAULT_ADDR), PKCS#11 (SoftHSM2) |
tap/30_rotate_kek_atomicity.t |
A KEK rotation that rolls back, fails later in its statement, or dies in a crash leaves every table readable; so do a session that read the tables (local: unlocked the wallet) before another session rotated the KEK, and a CREATE TABLE that waited on an aborted rotation; a rotation cancelled by statement_timeout after the key store changed can be run again, from the same session and from another — each checked right after and after a restart, once per provider: local, Vault (VAULT_ADDR), PKCS#11 (SoftHSM2) (PSQLE-185, PSQLE-209) |
tap/31_migrate_vault_to_wallet.t |
pg_vault_tde_migrate_vault_to_wallet() refuses a passphrase that does not open the wallet and leaves every migrated table readable under the local wallet — in a session opened before the migration, a new one and after a restart; the real-Vault half runs when VAULT_ADDR is set (PSQLE-188) |
tap/32_rotate_online_toast.t |
Out-of-line values — stored uncompressed and compressed — stay readable after pg_vault_tde_rotate_online(), after a restart, after a second rotation and after another restart, and the TOAST relation keeps the same number of live chunks (PSQLE-189). Runs once per available provider, as tap/29 |
tap/33_toast_lifecycle.t |
Out-of-line values go through every write path as on a plain heap twin put through the same statements — UPDATEs that keep, replace, inline or drop them, DELETE, VACUUM, DROP COLUMN, VACUUM FULL, two rotations and a restart; after each step the contents, a read of every whole row and the number of values left in the TOAST relation must match (PSQLE-189, 191, 192) |
tap/34_standby_rotation.t |
A streaming standby across rotate_online() on the primary: tables it had cached before the rotation read after it without an unwrap per row, and after a promotion new rows — including those of a table first touched by an INSERT — survive the promoted node’s restart; one table rotated twice, one first read after the rotation as the control (PSQLE-190) |
tap/35_rotate_online_indexes.t |
Every index keeps finding every row across pg_vault_tde_rotate_online() — PRIMARY KEY, UNIQUE, plain, partial and tde_btree: a lookup through each, a full range, amcheck heapallindexed, and duplicate keys refused, after two rotations, a VACUUM and a restart (PSQLE-194) |
tap/36_verify_integrity_toast.t |
pg_vault_tde_verify_integrity() counts a row whose out-of-line value no longer decrypts: one byte flipped in a TOAST chunk’s ciphertext (checksums off, as tap/20), the row counted once, the total still a row count, an untouched table clean, no resource left behind (PSQLE-196) |
tap/37_speculative_abort_toast.t |
An INSERT ... ON CONFLICT that loses the race to a concurrent insert leaves no out-of-line value behind, for DO NOTHING and DO UPDATE, on encrypted_heap and on a plain heap: the race is made deterministic with an expression index that blocks on an advisory lock, no injection points needed (PSQLE-197) |
tap/38_partial_index_build.t |
A partial index on encrypted_heap holds only the rows its predicate admits, against a plain heap twin: a valid UNIQUE ... WHERE builds and enforces, a partial btree has the heap twin’s size after CREATE INDEX and REINDEX, amcheck heapallindexed passes, a tde_btree partial index answers, reltuples still counts every row (PSQLE-198) |
tap/39_index_build_old_snapshot.t |
An index built while an older REPEATABLE READ snapshot is open gives that snapshot what a plain heap twin gives it — rows deleted after it, HOT-updated rows by their old values only; after two rotations under an open snapshot CREATE INDEX still builds and marks the index indcheckxmin; a parallel build passes amcheck (PSQLE-201) |
tap/40_cluster_order.t |
CLUSTER on encrypted_heap puts the rows in index order through both paths core can choose (index scan, sort), against a plain heap twin with out-of-line values and a dropped column; CLUSTER on a tde_btree index is refused; VACUUM FULL still works (PSQLE-204) |
tap/41_reencrypt_table_privileges.t |
pg_vault_tde_reencrypt_table() rewrites a table only for a role holding MAINTAIN on it: a pg_monitor member (both overloads, one SECURITY DEFINER) and a role granted EXECUTE alone are refused; the owner, a pg_maintain member and a superuser succeed; rotate_online() is unaffected (PSQLE-205) |
tap/42_security_definer_callers.t |
Every key-management function refuses any caller but a superuser, whoever holds EXECUTE: a pg_monitor member cannot create a database’s wallet, and a role granted EXECUTE on all ten is refused by each; a superuser still succeeds (PSQLE-206) |
tap/43_soak.t |
Soak test, skipped by default (make ci-soak, SOAK_MINUTES, SOAK_SEED): random INSERT ... ON CONFLICT, UPDATE and DELETE on an encrypted table and a heap twin, with random VACUUM, VACUUM FULL, CLUSTER, REINDEX, rotate_online(), rotate_kek() and immediate stops; after every round contents, whole rows, TOAST values, verify_integrity(), amcheck and index lookups (PSQLE-207) |
tap/44_damaged_wallet.t |
A local wallet truncated, empty, overwritten with random bytes, with one byte flipped, missing or unreadable: after a restart the server starts; reading, writing and creating an encrypted table fail with an ERROR; wallet_unlock(), rotate_kek(), change_passphrase() and wallet_init() fail and leave the file as it was; putting the file back restores every row. Leftover wallet.p12.new and .lock files are harmless; with the tables dropped, wallet_init() starts over (PSQLE-208) |
tap/45_verify_integrity_relcache_inval.t |
pg_vault_tde_verify_integrity() under a relcache invalidation of the table it scans: stopped on its first tuple (the DEK load waits on a lock), the table’s pg_class row is updated as autovacuum does, and every tuple must still verify (PSQLE-207) |
tap/46_rotate_online_interrupted.t |
rotate_online() held halfway through its rewrite (an expression index waits on an advisory lock at row 500 of 1000), then cancelled, terminated, or the server stopped immediately: every tag verifies, the table equals its heap twin (whole rows, TOAST values), amcheck finds every row, the DEK generation is unchanged, right after and after a restart; the progress row says failed (still running after an immediate stop, as documented); a new rotation completes — once per provider (PSQLE-211) |
tap/47_basebackup_tde_search_path.t |
pg_basebackup_tde calls the extension’s own pg_vault_tde_seal_keys_bytea() even when a database’s owner puts a schema with a function of the same name first in the database’s search_path: that function never runs, and the bundle written is the real one; a database with the extension in a schema off its search_path is sealed too (PSQLE-178) |
tap/48_iv_uniqueness.t |
No IV is used twice under one DEK: four sessions writing in turn, a restart and a rotation; every tuple of the heap and of its TOAST relation, dead versions included, is read off the raw pages and its (generation, IV) pair must be unique (PSQLE-178) |
tap/49_hot_update_short_indexed.t |
An UPDATE that changes a 1-byte indexed value (tde_btree on a 1-character text, btree on a bool) never goes HOT: 4000 UPDATEs, one per transaction, and after each the index finds the row by its new value and not by its old one, and UNIQUE refuses a duplicate; heap_update() compares ciphertext, which for a changed value of L bytes repeats with probability 256^-L (PSQLE-219) |
tap/19_crash_recovery_rmgr.t
Replays WAL written by the custom resource manager after an unclean shutdown.
pg_vault_tde_rmgr.c registers a resource manager whose rm_redo callback,
tde_rmgr_redo(), executes in exactly one situation: WAL replay — crash
recovery, PITR, or a standby applying the stream. Encrypted TOAST chunks are
routed through it by tde_toast_wal_insert() when
pg_vault_tde.toast_custom_rmgr is on.
Until this test existed, nothing in the suite ever replayed that WAL. The
$node->restart calls in other files are clean shutdowns, which checkpoint on
the way down and therefore replay nothing, so the redo path only ever ran on a
production system during recovery.
Three details are load-bearing, and each has an assertion guarding it:
stop('immediate')— SIGQUIT, no shutdown checkpoint, so everything since the last checkpoint must be replayed. The test assertsdatabase system was not properly shut downappears in the log after the restart; without it the test would keep passing if it silently stopped exercising recovery.STORAGE EXTERNAL+ incompressible payload — with the defaultEXTENDEDstorage PostgreSQL compresses these values inline, no TOAST chunks are written, and the custom rmgr is never reached. The test asserts the TOAST relation exceeds 8 kB so it cannot pass vacuously.- A per-row payload seed — the md5 over the whole column would not notice a row being replayed as a copy of its neighbour if every row held the same bytes.
Both branches of the toast_custom_rmgr GUC run: on is the path under test,
off is the control through heap_insert/RM_HEAP. If both fail, the fault is
in encryption or TOAST rather than in the resource manager.
tap/18_guc_order_independence.t
Pins the contract that the KMS GUCs behave as plain independent settings: a value set at database level overrides the cluster-level one, and nothing depends on the order or the scope in which they were set.
PostgreSQL applies a database’s pg_db_role_setting entries one at a time —
ProcessGUCArray() walks the setconfig array in order, and GUCArrayAdd()
replaces an existing name in place, so re-issuing an ALTER DATABASE SET
does not move it to the end — while process_settings() applies the
DATABASE_USER scope before the DATABASE one. Any provider init()
performed from the kms_provider assign hook therefore ran against a
half-applied configuration. The test covers the four cases that broke:
kms_providerset before the wallet GUCs — no spurious passphrase WARNING, and the encrypted round-trip works.- A custom
wallet_pathset afterkms_provideris honoured, and the per-database default path is not silently used instead. kms_provideratALTER ROLE … IN DATABASEscope with the wallet GUCs atALTER DATABASEscope — the case that reordering the statements cannot fix.pg_vault_tde_wallet_status()on a fresh connection reports the state of the wallet, not of the session: becauseinit()is lazy, status has to go through the provider accessor or it would answer “nothing opened it yet”.- Changing a passphrase source mid-session drops the KEK cached by
pg_vault_tde_wallet_unlock().
Plus a regression guard on the postmaster: a cluster-level local provider
must not attempt to open a wallet at startup, where there is no database and
therefore no wallet path.
Isolation Tests (test/isolation/specs/)
per_table_dek_rotation.spec: DEK rotation racing readers, writers and VACUUM.encrypted_rewrite_concurrency.spec:VACUUM FULL/CLUSTERwith a reader holding a snapshot across the relfilenode change, and with a writer to wait for.toast_update_concurrency.spec: anUPDATEreplacing an out-of-line value while another transaction updates or deletes the row, and aDELETEwaiting on anUPDATE; every permutation ends withVACUUMand counts the values left in the TOAST relation (PSQLE-193).
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.
Support Matrix
Which (OS, PostgreSQL major) combinations a package is built for, and how much verification stands behind each one.
Generated from packaging/build-matrix.json by
packaging/gen-support-matrix.sh. Edit the JSON and re-run the script; do not
edit the table below by hand, or the two disagree the next time anyone does run
it. No CI job enforces this — keeping them in step is part of changing the build
matrix.
- Functional suite — the regression, TAP, isolation, KMS and backup suites
run on this OS and PostgreSQL major (
make ci-all), building from source. - Package install — the built
.deb/.rpmis installed on a clean system of this OS andCREATE EXTENSIONis verified (ci/scripts/run-install-test.sh). - Neither — the package is compiled for that combination and nothing more.
The two columns are independent checks, not levels of one scale: a row may have its sources fully exercised while its package is never installed, and the other way round. Every combination requires OpenSSL 3.x, which is why distributions shipping only 1.1.1 (Debian 11, EL8) are absent from the matrix rather than listed as unsupported.
| Format | OS | PG | Functional suite | Package install |
|---|---|---|---|---|
| deb | ubuntu:22.04 |
17 | no | yes |
| deb | ubuntu:22.04 |
18 | no | yes |
| deb | ubuntu:24.04 |
17 | no | yes |
| deb | ubuntu:24.04 |
18 | no | yes |
| deb | ubuntu:26.04 |
17 | no | yes |
| deb | ubuntu:26.04 |
18 | no | yes |
| deb | debian:12 |
17 | no | yes |
| deb | debian:12 |
18 | no | yes |
| deb | debian:13 |
17 | yes | yes |
| deb | debian:13 |
18 | yes | yes |
| rpm | rockylinux:9 |
17 | no | yes |
| rpm | rockylinux:9 |
18 | no | yes |
| rpm | rockylinux:10 |
17 | no | yes |
| rpm | rockylinux:10 |
18 | no | yes |
| rpm | almalinux:9 |
17 | no | yes |
| rpm | almalinux:9 |
18 | no | yes |
| rpm | almalinux:10 |
17 | no | yes |
| rpm | almalinux:10 |
18 | no | yes |
KMS Provider Coverage
Which key-management backend each pg_vault_tde.kms_provider value is actually
exercised against in CI, and with what. Maintained by hand — unlike the table
above, nothing generates it.
| Provider | Backend under test | Version under test | Suites |
|---|---|---|---|
local |
PKCS#12 wallet on local disk; no external service | — | regress, wallet, checksums, isolation, schema, bench, tap/02_backup_local.t |
vault |
HashiCorp Vault, Transit secrets engine (ci/dump-compose.yml) |
2.1.0, pinned by digest in ci/containers/real-vault.Containerfile |
vault, tap/03_backup_vault.t |
openbao |
OpenBao, Transit secrets engine, 3-node Raft cluster (bao-1…bao-3 plus bao-init) |
2.6.2, pinned by digest in ci/compose-openbao*.yml |
openbao |
pkcs11 |
SoftHSM2 software token, created fresh per run in a tempdir | 2.6.1-3, from the base image’s Debian | pkcs11, tap/16_pkcs11.t |
Two things this table is saying, and one it is not:
- No physical HSM is exercised. The
pkcs11provider is verified against a software token only. A vendor module is loaded through the samedlopenpath, but no real device, PIN policy or slot behaviour is covered here — see PKCS#11 / HSM Provider for what the provider expects of one. - Both service images are pinned by version and digest (PSQLE-180), so a new
upstream release enters CI only when someone moves the pin — see PGXN.md,
Keeping pins current;
make ci-pinsfails on an unpinned one. OpenBao’s is overridable with$OPENBAO_IMAGEto try another version; Vault’s lives inci/containers/real-vault.Containerfile, which builds the localreal-vaultimage.
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 | 145 |
| v1.8 | KMIP + Column-Level + GIN/Hash/GiST/BRIN + HA + Dual-Control | 📋 Q2 2027 | ~160 |
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)