Extensions
- pg_vault_tde 1.7.2
- Transparent Data Encryption (TDE) for PostgreSQL via a custom Table Access Method
Documentation
- ROADMAP
- pg_vault_tde Roadmap
- RELEASE-KEYS
- RELEASE-KEYS
- packaging.instructions
- DevOps & Packaging Instructions — @DevOps
- v1.7.2
- Security review - pg_vault_tde 1.7.2 (self-review)
- CODE_OF_CONDUCT
- Code of Conduct
- v1.7.2
- Security evidence — pg_vault_tde 1.7.2
- SECURITY
- Security Policy
- CONTRIBUTORS
- Contributors
- v1.7.2.md
- v1.7.2.md
- BUILD_MATRIX
- Package Build Matrix
- kms.instructions
- KMS Module Instructions — @SecurityKMS
- iam.instructions
- IAM Module Instructions — @Architect + @SecurityKMS
- crypto.instructions
- Crypto Module Instructions — @SecurityKMS
- CONTRIBUTING
- Contributing
- SECURITY-REVIEW
- pg_vault_tde - Security Review
- tam.instructions
- TAM Module Instructions — @Architect
README
Contents
- pg_vault_tde
- Quick Start
- What Gets Encrypted
- Architecture
- Key Management
- GUC Parameters
- Auditing
- SQL Functions
- Access Methods
- Upgrading to 1.7.1
- Upgrading to 1.7.2
- Why
- What to run
- Also fixed: an all-NULL row made its table unreadable
- Custom WAL resource manager id: 128 → 161
- Renamed shared-memory objects
- tde_btree answers equality only
- KEK versions in the local wallet
- migrate_vault_to_wallet() before 1.7.2
- rotate_online() with concurrent access
- rotate_online() and out-of-line values
- Concurrent UPDATE of out-of-line values
- Dropped columns and out-of-line values
- Streaming standby and rotate_online()
- rotate_online() and indexes
- Partial indexes on encrypted tables
- Indexes built while an older snapshot is open
- CLUSTER on encrypted tables
- Who may call reencrypt_table()
- Who may call the key-management functions
- Who may call the read-only functions
- Tables created with toast_encryption = off
- Compatibility
- Testing
- Building
- Encrypted Backups
- 6. Executing pg_dump still produces a plain-text backup
- Running pg_vault_tde in Production
- Performance
- Limitations (v1.7)
- Community & Contributing
- License
- Copyright
pg_vault_tde
Transparent Data Encryption (TDE) for PostgreSQL 17+ — Open-source (PostgreSQL License), plug-and-play, zero core modifications.
pg_vault_tde encrypts every tuple with AES-256-GCM at the Table Access Method layer. Data is encrypted before it reaches the storage manager and decrypted after it leaves. Encryption keys are managed by HashiCorp Vault / OpenBao or a local PKCS#12 wallet and cached in shared memory with automatic rotation.
Current release: v1.7 — 141 regression tests (44 v1.4 + 20 v1.5 + 36 v1.6 + 41 v1.7), zero compiler warnings on PG 17 + PG 18.
Commercial Support
Looking for professional support for pg_vault_tde in production? At Miriade, we offer dedicated enterprise services, including:
- 24/7 Production Support & SLA Guarantees
- Custom Feature Development & Vault Integration
- Performance Tuning & Security Audits
- Managed Setup & Migration Assistance
Learn more about our enterprise encryption solutions at Mircrypt
Contact our engineering team at marketing@miriade.it to discuss your requirements.
Compatibility
PostgreSQL 17 and 18, 19 planned; OpenSSL 3.x required. Per-major API notes in Version Compatibility; packaged (OS, PG) combinations and what CI exercises on each in the Support Matrix.
Quick Start
Already running 1.7.0 or earlier? Do not upgrade to 1.7.1 or later before reading Upgrading to 1.7.1. Tables holding out-of-line TOAST values must be dumped before the new binary is installed.
Upgrading from 1.7.1? If
pg_vault_tde_rotate_online()has run since the last restart, act before restarting: copy out a rotated table that was being read or written — seerotate_online()with concurrent access — and runVACUUM FULLon a rotated table with out-of-line values — seerotate_online()and out-of-line values. A standby promoted while still on 1.7.1 must be restarted before its first write — see Streaming standby androtate_online(). Every table ever rotated needs aREINDEX— seerotate_online()and indexes — and so does every partial index on an encrypted table — see Partial indexes on encrypted tables. Check who created each database’s wallet — see Who may call the key-management functions. Otherwise nothing has to be done before installing 1.7.2, but existing encrypted tables need oneVACUUM FULLafterwards — see Upgrading to 1.7.2. Rows stay readable either way; until they are rewritten,UPDATEon some of them can take the backend down.
1. Install
# Build and install into your PostgreSQL instance
git clone https://github.com/labmiriade/pg_vault_tde.git
cd pg_vault_tde
# The PostgreSQL packages below come from the PGDG repository for any major your
# distribution does not ship itself — PG 18 on Debian 13, every major on
# RHEL/Rocky. Set it up first if you have not already:
# https://www.postgresql.org/download/
# On RHEL/Rocky also run: dnf -qy module disable postgresql
# On Debian/Ubuntu (PG 18) — server-dev pulls in the clang/llvm PGXS needs for bitcode
apt-get install -y build-essential postgresql-server-dev-18 \
libssl-dev libcurl4-openssl-dev pkg-config
make && sudo make install
# On Debian/Ubuntu (PG 17)
apt-get install -y build-essential postgresql-server-dev-17 \
libssl-dev libcurl4-openssl-dev pkg-config
make PG_CONFIG=/usr/lib/postgresql/17/bin/pg_config && sudo make install
# On RHEL/Rocky — EPEL and CRB first: postgresqlNN-devel needs perl(IPC::Run)
dnf install -y epel-release
dnf config-manager --set-enabled crb
# On RHEL/Rocky (PG 18) — clang/llvm-devel are NOT pulled in by postgresqlNN-devel,
# and redhat-rpm-config provides the hardening spec file pg_config injects
dnf install -y postgresql18-devel openssl-devel libcurl-devel \
gcc make redhat-rpm-config clang llvm-devel
make PG_CONFIG=/usr/pgsql-18/bin/pg_config && make install
# On RHEL/Rocky (PG 17)
dnf install -y postgresql17-devel openssl-devel libcurl-devel \
gcc make redhat-rpm-config clang llvm-devel
make PG_CONFIG=/usr/pgsql-17/bin/pg_config && make install
Or via PGXN (same OS build
dependencies as above are still required — pgxn install just runs the
build for you):
pip install pgxnclient # or: apt-get install pgxnclient / dnf install pgxnclient
pgxn install pg_vault_tde
See wiki: Installation
for package-based (.deb/.rpm) installs and full per-OS prerequisites.
2. Configure PostgreSQL
Add to postgresql.conf:
shared_preload_libraries = 'pg_vault_tde'
Check the WAL resource manager id first. pg_vault_tde registers a custom WAL resource manager under id 161, reserved for it on the PostgreSQL Custom WAL Resource Managers wiki. Extensions that follow the registry never use it, but an unregistered one — typically in-house or proprietary — can. On every node that will load pg_vault_tde or replay its WAL (primary, standbys, PITR restore hosts), this must return no rows before you add it:
SELECT rm_id, rm_name FROM pg_get_wal_resource_managers() WHERE rm_id = 161 OR rm_name = 'pg_vault_tde';If it returns one, the server will refuse to start once pg_vault_tde is preloaded (
failed to register custom resource manager "pg_vault_tde" with ID 161). After the restart the same query must return exactly161 | pg_vault_tde. Any role can run it.
Restart PostgreSQL and create the extension in your database:
CREATE EXTENSION pg_vault_tde;
3. Configure Key Access
a) HashiCorp Vault / OpenBao
kms_provider has no built-in default — it must be set explicitly. Set GUC
parameters in postgresql.conf (or ALTER SYSTEM) to point at your Vault /
OpenBao instance:
pg_vault_tde.kms_provider = 'vault'
pg_vault_tde.vault_url = 'https://vault.example.com:8200'
pg_vault_tde.vault_namespace = '' # leave empty for community edition
pg_vault_tde.vault_token = 'hvs.TOKEN' # or use AppRole (1.1)
pg_vault_tde.vault_transit_mount = 'transit'
pg_vault_tde.vault_key_name = 'pg-tde-dek'
pg_vault_tde.vault_ca_cert = '/etc/ssl/vault/ca.pem'
pg_vault_tde.vault_timeout_ms = 5000
pg_vault_tde.enabled = on # PGC_POSTMASTER: requires a full restart to change
⚠️ Warning — do not toggle
pg_vault_tde.enabledon a live database. Rows are written using the wire format active at the time of the write (encrypted v4 trailer whenon, verbatim heap tuple whenoff). Changing the setting and restarting does not retroactively convert existing rows: reads use whatever format is currently active for the entire table, so anyencrypted_heaptable containing rows written under the other setting will have those old rows misread (silent data corruption, not an error). Only toggle this setting on databases whereencrypted_heaptables are empty or have been fully migrated (e.g. rewritten viaCREATE TABLE ... AS SELECTunder the target setting) beforehand.
b) Local Wallet (keypass locale, v1.6+)
To use a local PKCS#12 wallet (no external KMS, suitable for offline/air-gapped/standalone):
- Set the following in
postgresql.conf:
pg_vault_tde.kms_provider = 'local'
# wallet_path defaults to /var/lib/pg_vault_tde/<DB_OID>/wallet.p12 — omit unless overriding:
# pg_vault_tde.wallet_path = '/custom/path/to/wallet.p12'
pg_vault_tde.wallet_passphrase_env = 'TDE_WALLET_PASSPHRASE' # env var name only
pg_vault_tde.wallet_auto_open = on
pg_vault_tde.enabled = on
- Set the passphrase in the environment before starting PostgreSQL:
export TDE_WALLET_PASSPHRASE='my-strong-wallet-passphrase'
- Initialize the wallet (first time only, as superuser):
-- In psql: \set reads the shell variable without exposing the value in
-- pg_stat_activity or server logs (note: backslash-set is a psql meta-command)
\set PASSPHRASE `echo $TDE_WALLET_PASSPHRASE`
SELECT pg_vault_tde_wallet_init(:'PASSPHRASE');
- Check wallet status:
SELECT * FROM pg_vault_tde_wallet_status();
- Unlock/lock wallet interactively (no restart needed):
SELECT pg_vault_tde_wallet_unlock('my-strong-wallet-passphrase');
SELECT pg_vault_tde_wallet_lock();
Tip: You can also use
wallet_passphrase_fileorwallet_passphrase_commandinstead of an environment variable. See the GUC Parameters section for details.
c) PKCS#11 / HSM (v1.7)
To keep the KEK inside a hardware security module (or any device exposing a
PKCS#11 module — Thales, Utimaco, YubiHSM, AWS CloudHSM, SoftHSM2 for
testing). The extension loads the vendor’s module directly and wraps every
per-table DEK with C_WrapKey (CKM_AES_KEY_WRAP, RFC 3394) against an
AES-256 KEK that never leaves the token.
- Set the following in
postgresql.conf:
pg_vault_tde.kms_provider = 'pkcs11'
pg_vault_tde.pkcs11_library = '/usr/lib/softhsm/libsofthsm2.so' # vendor module
pg_vault_tde.pkcs11_token_label = 'pgtde' # preferred over pkcs11_slot_id
# pg_vault_tde.pkcs11_pin_env = 'PG_TDE_PKCS11_PIN' # env var NAME (default)
# pg_vault_tde.pkcs11_key_label = 'pg_vault_tde_kek' # KEK CKA_LABEL (default)
pg_vault_tde.enabled = on
- Export the token user PIN in the server environment before starting PostgreSQL (the GUC holds the env var name, never the PIN itself):
export PG_TDE_PKCS11_PIN='1234'
- Generate the KEK on the token (first time only, as superuser):
SELECT pg_vault_tde_pkcs11_keygen();
The KEK is created with CKA_SENSITIVE and CKA_EXTRACTABLE=FALSE: it can
never be read out of the device. KEK rotation goes through the standard
SELECT pg_vault_tde_rotate_kek(); — each generation is kept on the token
forever as an immutable object labelled <pkcs11_key_label>.v<N> (never
renamed or destroyed), so old data always decrypts regardless of which
version is “current”. A backend that is already connected when a rotation
completes elsewhere picks it up automatically, on its next encrypt/decrypt
call — no reconnect required.
Testing without an HSM: initialize a SoftHSM2 token with
softhsm2-util --init-token --free --label pgtde --pin 1234 --so-pin 12345(packagesofthsm2; setSOFTHSM2_CONFfor a custom token directory). Seetap/16_pkcs11.tfor a complete self-contained example.Limitation: the standalone backup tools (
pg_dump_tde/pg_restore_tde) do not supportkms_provider = 'pkcs11'yet and exit with a clear error.
4. Create an Encrypted Table
CREATE TABLE users (
id bigserial PRIMARY KEY,
email text,
ssn text,
dob date
) USING encrypted_heap;
INSERT INTO users (email, ssn, dob)
VALUES ('alice@example.com', '123-45-6789', '1990-01-15');
-- Data is transparently decrypted on read
SELECT email, ssn FROM users WHERE id = 1;
Before going to production, read Running pg_vault_tde in Production: primary keys, partitions, statistics, temporary files and backups all have ways of putting plaintext on disk.
What Gets Encrypted
| Layer | Encrypted? | Notes |
|---|---|---|
| Tuple user data | ✅ Yes — AES-256-GCM | All column values in encrypted_heap tables |
| HeapTupleHeader | ✗ No | xmin, xmax, ctid, infomask — required for MVCC |
| Index keys (B-Tree) | ⚠️ Optional — tde_btree |
AES-256-SIV — equality only; all types encrypted (v1.7); index-only scans not supported |
| Index keys (GIN, Hash) | 🔜 v1.8 | GIN for jsonb/arrays; Hash for equality hashing |
| Index keys (GiST equality) | 🔜 v1.8 | Equality-only GiST (inet_ops); range/geometric GiST permanently deferred |
| Index keys (BRIN bloom) | 🔜 v1.8 | Equality-only block-range pruning via a bloom filter over ciphertext hashes; minmax BRIN permanently deferred (needs a spike — see doc/ROADMAP.md) |
| TOAST values | ✅ Yes | Heap-level round-trips functional; per-chunk storage encryption |
| Column-level granularity | 🔜 v1.8 | Per-column ENABLE COLUMN ENCRYPTION DDL |
| WAL / redo log | ✅ Yes | Data encrypted before heap_insert() |
| pg_statistic | 🔜 v1.8 | Statistics stored plaintext; MCVs/histograms expose value distribution |
Column-level: Only tables created with
USING encrypted_heapare encrypted. Regularheaptables are unaffected.Index access method whitelist:
CREATE INDEX/CREATE UNIQUE INDEXwith any access method other thantde_btree(so alsogin,gist,hash,brin) against anencrypted_heaptable is rejected withERRORby default — the indexed column’s plaintext value would otherwise sit unencrypted on disk. Setpg_vault_tde.allow_plaintext_index = onto allow it anyway (with aWARNING) until GIN/Hash/GiST encryption ships in v1.8.PRIMARY KEY/UNIQUEtable constraints are a separate, unavoidable case — PostgreSQL core always backs them with a native btree index — and are always allowed with aWARNING, regardless of this setting.
Architecture
SQL Layer
│
▼
Table Access Method (TAM) — encrypted_heap src/tam/
│ ┌─ tuple_insert ──► tde_encrypt_heap_tuple ──► heap_insert
│ ├─ tuple_update ──► tde_encrypt_heap_tuple ──► heap_update
│ ├─ multi_insert ──► tde_encrypt × N ──────► heap_insert × N
│ ├─ scan_getnextslot ──► heapam ──► decode_slot ──► tde_decrypt
│ ├─ index_fetch_tuple ──► heapam ──► decode_slot ──► tde_decrypt
│ ├─ scan_bitmap_next_tuple ──► heapam ──► decode_slot ──► tde_decrypt
│ ├─ scan_analyze_next_tuple ──► heapam ──► decode_slot ──► tde_decrypt
│ ├─ scan_sample_next_tuple ──► heapam ──► decode_slot ──► tde_decrypt
│ ├─ tuple_fetch_row_version ──► heapam ──► decode_slot ──► tde_decrypt
│ └─ tuple_lock ──► heapam ──► decode_slot ──► tde_decrypt
│
▼
Index Access Method (IAM) — tde_btree src/iam/
│ AES-256-SIV (OpenSSL 3.x EVP_CIPHER_fetch) — deterministic equality
│ 64-byte double-key via PBKDF2-SHA256 from DEK
│
▼
Crypto Layer — AES-256-GCM (OpenSSL 3.x EVP) src/crypto/
│ [attributes, values encrypted | IV(12) | GCM-TAG(16) | VER(1) | GEN(8)]
│ Per-backend EVP_CIPHER_CTX cached & keyed by (relid, generation):
│ AES key schedule reused across tuples, only the IV rearmed per call
│ IV batch generation: 256 IVs per pg_strong_random() call
│
▼
KMS Layer — per-relation DEK cache src/kms/
│ ┌─ TdeRelDekMap (shmem HTAB keyed by (dbid, relid), one shared LWLock)
│ └─ pg_vault_tde_catalog (on-disk wrapped DEKs, one row per relation,
│ one table per database — see "DEK Cache (Shared Memory)")
│
▼
HashiCorp Vault / OpenBao (GUC-configurable endpoint)
Wire Format (on disk, per tuple)
v5 format (written by encrypted_heap tables; v4 is still read):
┌─────────────────────────────────┬───────────────────────────────────────────────────────┐
│ HeapTupleHeader (t_hoff bytes) │ attributes, VALUES encrypted │ IV(12) │ Tag(16) │V│G│
│ PLAINTEXT — MVCC fields │ (attribute layout preserved) │ │
└─────────────────────────────────┴───────────────────────────────────────────────────────┘
←──── 37 bytes ────→
Overhead: 37 bytes per tuple (12-byte IV + 16-byte GCM authentication tag +
1-byte version 0x05 + 8-byte DEK generation counter) — the same as v4, so a v5
tuple is exactly as long as the v4 tuple for the same row.
v5 is structure preserving: every attribute keeps its offset and its length,
and only the bytes of the values are ciphertext. The structural bytes — varlena
length headers, the external-datum tag, alignment padding — stay in clear,
because PostgreSQL itself walks the on-disk tuple: heap_update() reads the
indexed attributes straight off the page to decide HOT and index maintenance.
Under v4 that walk read attribute boundaries out of one opaque blob and crashed
the backend (PSQLE-165). The cost of v5 is that the exact byte length of each
variable-length column is visible on disk; fixed-length columns leak nothing,
and the row length and null bitmap were already visible under v4.
v4 tuples are read transparently, but an existing table only moves to v5 when its
rows are rewritten — VACUUM FULL does it.
Both versions pass [database_oid(4) | relid(4) | generation(8)] as AEAD Additional
Authenticated Data (AAD) — zero wire overhead; prevents cross-table ciphertext smuggling.
Key Management
KMS Provider Selection
pg_vault_tde supports multiple KMS backends via a provider abstraction layer
(introduced in v1.5). kms_provider has no built-in default — it is an
empty string until set, which the extension treats as “not yet configured.”
Select the provider explicitly with:
pg_vault_tde.kms_provider = 'vault' # HashiCorp Vault / OpenBao
# pg_vault_tde.kms_provider = 'local' # Local wallet (PKCS#12, no external service) (v1.6)
# pg_vault_tde.kms_provider = 'pkcs11' # HSM via a PKCS#11 module (v1.7)
# pg_vault_tde.kms_provider = 'kmip' # KMIP 1.2 (v1.8, not implemented yet)
Per-Database KMS Configuration
Because all pg_vault_tde KMS-provider GUC parameters are declared PGC_SUSET (the master enabled switch and a couple of shared-memory-sizing parameters are PGC_POSTMASTER and cannot be scoped per database — see doc/pg_vault_tde.md), a superuser can assign different KMS settings to individual databases in the same cluster without restarting PostgreSQL.
Each connection picks up the effective GUC value for its own database, so postgres can use a central Vault instance while tenant_a uses a dedicated transit key and tenant_b uses a local wallet:
-- cluster-level default (postgresql.conf / ALTER SYSTEM)
-- pg_vault_tde.kms_provider = 'vault'
-- database "tenant_a" uses a dedicated Vault transit key
ALTER DATABASE tenant_a SET pg_vault_tde.vault_key_name = 'tde-dek-tenant-a';
ALTER DATABASE tenant_a SET pg_vault_tde.vault_transit_mount = 'transit-tenants';
-- database "tenant_b" uses a local wallet (no Vault dependency)
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 settings for a database
\connect tenant_b
SHOW pg_vault_tde.kms_provider; -- 'local'
SELECT pg_vault_tde_health_check();
Settings applied with ALTER DATABASE SET take effect for new connections
to that database and do not require a server restart. The cluster-level defaults
in postgresql.conf (or ALTER SYSTEM) act as the fallback for any database
that does not override a parameter.
Order and scope do not matter
Every pg_vault_tde KMS parameter is an ordinary, independent GUC:
- a value set at database level always overrides the cluster-level one, and
- the order in which the
ALTER DATABASE SETstatements are issued, and the scope each one is set at, are irrelevant.
You can therefore set kms_provider first, last, or in the middle, and mix
ALTER SYSTEM, ALTER DATABASE SET and ALTER ROLE … IN DATABASE … SET
freely. The provider reads its configuration when it is first used to wrap or
unwrap a key — after PostgreSQL has finished applying every setting that
applies to the connection — not at the moment kms_provider is assigned.
Versions before this fix initialised the provider from the
kms_providerGUC assign hook, i.e. while PostgreSQL was still applying the database’s settings one at a time. Settingkms_providerbefore the wallet parameters produced a spuriouslocal wallet passphrase env var "" not setWARNING on every connection and, worse, silently frozewallet_pathto the per-database default. If you are upgrading and had worked around this by re-ordering yourALTER DATABASE SETstatements, that workaround is no longer needed (and was never reliable at mixed scopes). Regression coverage:tap/18_guc_order_independence.t.
To inspect where each effective value comes from, use PostgreSQL’s own
pg_settings.source (database, configuration file, session, …):
SELECT name, setting, source
FROM pg_settings
WHERE name LIKE 'pg_vault_tde.%' AND source <> 'default'
ORDER BY name;
Local Wallet Provider (v1.6 — Offline, No External Service)
A PKCS#12-based encrypted file at
/var/lib/pg_vault_tde/<DB_OID>/wallet.p12 protects the KEK — deliberately
outside PGDATA, so a plain pg_basebackup does not copy it alongside the
wrapped DEKs it protects. No network dependency.
Suitable for single-server deployments, air-gapped environments, and development.
The file keeps every KEK version (since 1.7.2): rotate_kek() and
wallet_change_passphrase() add one and never remove any, and write it before a
single DEK is re-wrapped. A rotation that rolls back, fails later in its statement
or dies in a crash therefore loses nothing — every DEK still unwraps with a version
the file holds. See KEK versions in the local wallet.
pg_vault_tde.kms_provider = 'local'
# wallet_path defaults to /var/lib/pg_vault_tde/<DB_OID>/wallet.p12 — omit unless overriding:
# pg_vault_tde.wallet_path = '/custom/path/to/wallet.p12'
pg_vault_tde.wallet_passphrase_env = 'TDE_WALLET_PASSPHRASE' # env var, never postgresql.conf
pg_vault_tde.wallet_auto_open = on
-- First-time wallet setup (\set reads the shell var without exposing it in logs):
\set PASSPHRASE `echo $TDE_WALLET_PASSPHRASE`
SELECT pg_vault_tde_wallet_init(:'PASSPHRASE');
-- Check status (5-column SRF):
SELECT * FROM pg_vault_tde_wallet_status();
-- Interactive unlock (without PG restart):
SELECT pg_vault_tde_wallet_unlock('my_passphrase');
-- Lock wallet (evict DEKs from shmem):
SELECT pg_vault_tde_wallet_lock();
-- Rotate KEK: generates a new KEK and re-wraps all per-table DEKs (works for the local, vault, and pkcs11 providers):
SELECT pg_vault_tde_rotate_kek();
Keeping the wallet on a network share
The wallet file is read far more often than its size suggests. The KEK is
not cached between operations — it is re-derived from the file on every
wrap and unwrap, deliberately, so that it does not sit in process memory
between them. A backend that gets its passphrase from
wallet_passphrase_env / _command (rather than from an interactive
pg_vault_tde_wallet_unlock(), which does cache it for that session)
therefore opens and parses the PKCS#12 once per DEK. A startup warm-up with
preload_keys does it once per relation.
On local storage that is unremarkable. On NFS or SMB it means one network round-trip per unwrap, so:
- Mount it with client-side caching enabled — the file changes only on
wallet_init,wallet_change_passphraseandrotate_kek, so it caches well. On NFS keep the default attribute and data caching (do not mountnoacoractimeo=0);fscwithcachefilesdhelps further on a slow link. - Know what caching does not fix. It removes the network round-trip, not the PBKDF2 derivation, which is CPU and runs every time regardless. If the warm-up is slow on local disk too, that is what you are measuring.
- Invalidate after a key operation. A cached copy is a stale KEK after
wallet_change_passphrase()orrotate_kek(). On a single server the writes invalidate the local cache. If several hosts read the same wallet file, drop their caches before they next touch an encrypted table — and see the warning under “Key Rotation” first, because a wallet shared between databases cannot have its KEK rotated safely at all. - Raise
preload_max_failures. A network share has transient failures a local disk does not, and the default of 5 is tuned for the local case.
If none of that is appealing, keep the wallet on local storage and replicate it out of band — it is one small file that changes only when you rotate.
Production (HashiCorp Vault / OpenBao)
The KMS layer calls Vault’s Transit secrets engine:
# Vault policy
path "transit/decrypt/pg-tde-dek" {
capabilities = ["update"]
}
path "transit/encrypt/pg-tde-dek" {
capabilities = ["update"]
}
The Vault endpoint, namespace, token, transit mount, key name, CA certificate and timeout are all configurable via GUC parameters registered at startup (see Configure Key Access).
DEK Cache (Shared Memory)
Since v1.7 the cache is a shared-memory hash table (HTAB), not a fixed array.
A single LWLock from the "pg_vault_tde_rel_dek_map" named tranche guards the whole table
(no per-entry lock).
TdeRelDekMap (shmem HTAB, ShmemInitHash, capacity = pg_vault_tde.max_encrypted_relations, default 1024)
└─ TdeRelDekMap entry, keyed by (dbid, relid):
├─ key : TdeRelDekMapKey (hash key)
├─ dek[32] : AES-256 key bytes (OPENSSL_cleanse'd on rotation)
├─ prev_dek[32] : previous DEK (rotation window fallback)
├─ generation : uint64 per-relation counter
└─ dek_valid / prev_dek_valid : bool
The key is (dbid, relid), not relid alone. A relid is unique only
within a database — never across databases, and never cluster-wide — while
this HTAB is one segment read by the backends of every database. CREATE
DATABASE is a physical copy of the template’s directory, so a cloned database
hands out pg_class OIDs identical to its template’s; two unrelated databases
sharing a relid is normal, not a corner case.
typedef struct TdeRelDekMapKey
{
Oid dbid; /* always MyDatabaseId */
Oid relid; /* effective relid: TOAST → parent, relrewrite → base */
} TdeRelDekMapKey;
dbid is not part of any public signature: the catalog module fills it from
MyDatabaseId when it builds the key. Every path that reaches the cache runs
connected to the database that owns both the relation and its
pg_vault_tde_catalog row — a regular backend, the rotation BGW after
BackgroundWorkerInitializeConnectionByOid(), a walsender during logical
decoding — so deriving it in one place makes “caller passed the wrong dbid”
unrepresentable.
The on-disk side needs no such key. pg_vault_tde_catalog is an ordinary
table created by CREATE EXTENSION in the extension’s schema, so it already
exists once per database and its relid primary key is unambiguous there; the
local wallet is likewise per-database, at
/var/lib/pg_vault_tde/<db_oid>/wallet.p12. Shared memory was the one place
where per-database namespaces met, and the one place that needed the dbid.
Note that max_encrypted_relations sizes a single cluster-wide segment: with
encrypted tables in several databases, budget for their sum.
DEK access via pg_vault_tde_kms_get_rel_dek(relid):
1. Fast path: hash_search(HASH_FIND) under LW_SHARED — O(1) average; cache hit returns immediately.
2. Slow path (cache miss): catalog read (pg_vault_tde_catalog) -> KMS unwrap -> hash_search(HASH_ENTER) under LW_EXCLUSIVE.
Key Rotation
Per-table DEK rotation (re-encrypts all tuples with a new DEK; reads continue, writes wait until it finishes):
SELECT pg_vault_tde_rotate_online('mytable', 1000);
-- Monitor progress:
SELECT * FROM pg_vault_tde_get_rotation_status('mytable');
rotate_online accepts both table relations and tde_btree index relations:
| Target | What happens |
|---|---|
encrypted_heap table |
Generates a new table DEK, re-encrypts every tuple in-place in one transaction (ShareRowExclusiveLock on the table: SELECT continues, writes and a second rotation wait), then rebuilds any tde_btree indexes on the table so their SIV ciphertexts match the new DEK. Every other index — the PRIMARY KEY and UNIQUE constraints included — gets an entry for each rewritten row, as with an UPDATE. |
tde_btree index |
Generates a new index DEK, then calls reindex_index (AccessExclusiveLock on the index, ShareRowExclusiveLock on its table) to rebuild the index with keys encrypted under the new DEK. The parent table’s DEK and heap data are untouched. Passing a non-tde_btree index raises an error before touching shmem or the catalog. |
When a table with tde_btree indexes is rotated, the index rebuild uses the new table DEK
implicitly because the heap rows the scan reads are re-encrypted first; the index keys
are then produced from the decrypted values and re-encrypted under the (unchanged) index DEK.
To also rotate the index DEK, call rotate_online on the index relation directly afterwards.
Logical replication: a rotation reaches subscribers as one
UPDATEper row, and a logical slot must decode past it before the publisher restarts or the same table is rotated again — the previous DEK lives only in shared memory until then. A slot that falls behind that point stops withpg_vault_tde: decryption failed, and so does every other slot of the database. Checkconfirmed_flush_lsninpg_replication_slots.
KEK rotation (re-wraps all per-table DEKs under a new KEK — tuple data untouched):
-- Unified function — works for the local wallet, Vault Transit, and PKCS#11 providers:
SELECT pg_vault_tde_rotate_kek();
Note on
pg_vault_tde_wallet_change_passphrase(old, new): this function automatically rotates the KEK as part of the passphrase change. A separatepg_vault_tde_rotate_kek()call is unnecessary afterwards. The rationale: if an attacker already holds the old passphrase, they already have the old KEK — changing the passphrase without rotating the KEK provides no additional protection.KEK rotation is per-database. Both
pg_vault_tde_rotate_kek()andpg_vault_tde_wallet_change_passphrase()rewrap only thepg_vault_tde_catalogof the database they run in — that table is per-database and no backend can reach another database’s copy. With the default per-databasewallet_path(/var/lib/pg_vault_tde/<DB_OID>/wallet.p12) that is the whole wallet. Setwallet_pathto one shared file and it is not: up to 1.7.1 the rotation then replaced that file, and the first database to rotate made every other database’s data permanently unreadable. Since 1.7.2 the file keeps every KEK version, so the other databases stay readable under the version they were wrapped with — but each one moves to the new KEK only when it rotates too, and awallet_change_passphrase()in one database changes the passphrase for all of them.Since
pg_vault_tde_wallet_init()persists the resolved path per database (ALTER DATABASE ... SET FROM CURRENT), and a database-level setting wins overpostgresql.conf, sharing a wallet is something you have to ask for explicitly on each database rather than something you fall into. When the effective wallet is not this database’s own default file, both rotation paths emit aWARNINGnaming the database they actually covered.Note this is about the KEK, not about relids. Per-table DEKs never collide across databases: each is 32 independent random bytes in its own database’s catalog, and the GCM AAD binds
MyDatabaseId, so one database’s key can never silently decrypt another’s rows.
One key operation at a time
Each key operation is tested on its own, and against the reads and writes running
beside it — not against the others: rotate_online() (of a table or of a
tde_btree index), rotate_kek(), wallet_change_passphrase(), wallet_lock() /
wallet_unlock(), migrate_vault_to_wallet(), seal_keys() / unseal_keys() and
reencrypt_table(). Until they are (1.8), run them one at a time in each database,
and start the next only once the previous one has returned and no rotation worker is
left:
SELECT count(*) FROM pg_stat_activity WHERE backend_type = 'pg_vault_tde rotation'; -- 0
pg_vault_tde_rotation_status says how each rotate_online() ended: complete, or
failed when it hit an error or was stopped — pg_cancel_backend(),
pg_terminate_backend(), a smart or fast shutdown. A rotation is one transaction, so a
failed one has changed nothing: run it again. After a crash or an immediate shutdown
its row stays running with no worker behind it; that is the same case. Up to 1.7.1 a
terminated rotation, or one stopped by a fast shutdown, stayed running too.
In particular:
- no
rotate_kek()orwallet_change_passphrase()while arotate_online()runs; - no
wallet_lock()while a rotation runs; - no second
rotate_online(), of another table either, before the first iscomplete; - no
unseal_keys()ormigrate_vault_to_wallet()while a rotation runs; - no
DROP,TRUNCATEorALTER TABLEof a table being rotated.
GUC Parameters
All parameters are in the pg_vault_tde namespace.
Most parameters have context suset (superuser-settable), meaning a superuser
can change them without restarting PostgreSQL and can scope them per-database
with ALTER DATABASE SET. The only exception is max_encrypted_relations,
which has context postmaster because it controls shared memory allocation at
startup.
Context summary:
- suset — superuser can SET at session level or via ALTER DATABASE SET /
ALTER ROLE SET; takes effect for new connections with no restart required.
- postmaster — requires a server restart; set in postgresql.conf or via
ALTER SYSTEM.
KMS Provider (v1.5+)
| Parameter | Type | Default | Context | Description |
|---|---|---|---|---|
kms_provider |
string | '' (unset — must be configured) |
suset | Active KMS backend: vault, local (v1.6), pkcs11 (v1.7), kmip (v1.8). No default is shipped; encrypted tables cannot be used until this is set. Settable per-database via ALTER DATABASE SET. |
wallet_path |
string | /var/lib/pg_vault_tde/<DB_OID>/wallet.p12 |
suset | Local wallet PKCS#12 file path (kms_provider = 'local'). Default computed at runtime — SHOW returns the effective path even when not set in postgresql.conf. Deliberately outside PGDATA so a plain pg_basebackup does not copy it. Setting this in postgresql.conf makes every database share one KEK — see the warning under “Key Rotation” before doing so. |
wallet_passphrase_env |
string | '' |
suset | Env var name holding wallet passphrase — env var NAME only, never the value |
wallet_passphrase_file |
string | '' |
suset | File path containing wallet passphrase (trimmed; 0400 permission enforced) (v1.6) |
wallet_passphrase_command |
string | '' |
suset | Shell command to retrieve passphrase (analogous to PG’s ssl_passphrase_command) (v1.6) |
wallet_dev_mode_passphrase |
string | '' |
suset | Convenience passphrase for dev/CI (only honoured when dev_mode = on) — shown only to a superuser (others read ********), not in pg_settings (v1.7.2, PSQLE-224) (v1.6) |
dev_mode |
boolean | off |
suset | Enable development mode features (wallet_dev_mode_passphrase) (v1.6) |
wallet_auto_open |
boolean | on |
suset | Auto-open wallet on startup if passphrase env var is set |
preload_keys |
boolean | off |
suset | Warm this database’s DEK cache at startup: a background worker per database unwraps every DEK in pg_vault_tde_catalog once the server accepts connections, so the first query on a table does not pay a KMS round-trip. Needs a KMS usable without an interactive unlock (wallet_passphrase_command / wallet_passphrase_env). Stops at max_encrypted_relations. Scope it with ALTER DATABASE SET. |
preload_max_failures |
integer | 5 |
suset | Consecutive DEK unwrap failures the startup preload tolerates in one database before giving up on it. Consecutive, so a missing passphrase stops the pass at once while a one-off does not. Relevant to the local wallet too: the KEK is re-derived from the wallet file on every unwrap rather than cached, so a wallet on NFS or SMB is reopened once per relation. 0 stops at the first failure. |
max_encrypted_relations |
integer | 1024 |
postmaster | Maximum number of 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. |
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. Up to 1.7.1, a table created or rewritten with it off got a heap TOAST table of encrypted chunks and could not read its out-of-line values — see Tables created with toast_encryption = off to repair one |
Vault / OpenBao (kms_provider = 'vault')
All parameters are suset — settable per-database with ALTER DATABASE SET.
| Parameter | Type | Default | Context | Description |
|---|---|---|---|---|
vault_url |
string | '' |
suset | Vault / OpenBao base URL |
vault_namespace |
string | '' |
suset | Vault namespace (enterprise; empty for community) |
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 after login (v1.4) |
vault_k8s_role |
string | '' |
suset | Kubernetes JWT auth role name |
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 verification |
vault_timeout_ms |
integer | 5000 |
suset | Vault HTTP timeout in ms (0 = no timeout) |
PKCS#11 / HSM (kms_provider = 'pkcs11') (v1.7)
All parameters are suset — settable per-database with ALTER DATABASE SET.
| Parameter | Type | Default | Context | Description |
|---|---|---|---|---|
pkcs11_library |
string | '' |
suset | Absolute path to the vendor’s PKCS#11 module (.so). Loaded lazily per backend. |
pkcs11_token_label |
string | '' |
suset | Token label for slot discovery. Preferred over pkcs11_slot_id (slot IDs are not stable across restarts on some modules, e.g. SoftHSM2). |
pkcs11_slot_id |
integer | -1 |
suset | Explicit slot ID, used only when pkcs11_token_label is empty (-1 = unset) |
pkcs11_pin_env |
string | PG_TDE_PKCS11_PIN |
suset | Env var name holding the token user PIN — env var NAME only, never the value |
pkcs11_key_label |
string | pg_vault_tde_kek |
suset | CKA_LABEL of the AES-256 KEK object on the token (create with pg_vault_tde_pkcs11_keygen()) |
Background Worker
| Parameter | Type | Default | Context | Description |
|---|---|---|---|---|
bgw_enabled |
boolean | off |
suset | Enable background worker for automatic token renewal |
token_renewal_interval |
integer | 3600 |
suset | Token renewal interval in seconds (60–86400) |
General
| Parameter | Type | Default | Context | Description |
|---|---|---|---|---|
enabled |
boolean | on |
suset | Master switch — set off to measure TAM overhead without crypto. Settable per-database. |
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, allowed after a WARNING — the indexed column’s plaintext value is then stored unencrypted on disk. Governs the v1.5 tde_btree operator classes that keep keys in plaintext (tde_int4_ops, tde_int8_ops, tde_uuid_ops, tde_date_ops, tde_timestamptz_ops) the same way since 1.7.2 — which is also what restoring a dump that names one needs. Does not affect PRIMARY KEY/UNIQUE table constraints (always allowed, always warned — see “What Gets Encrypted” above). |
Auditing
pg_vault_tde emits an audit record for every security-relevant KMS and DDL event.
Auditing is always active: the audit handler is registered unconditionally at
_PG_init time and there is no GUC to disable it.
Each event is written to the PostgreSQL server log at LOG severity via
ereport(LOG) with errhidestmt and errhidecontext set, so the originating
SQL statement and context stack are suppressed — only the audit fields appear.
Log format
AUDIT: event=<name>, oid=<relation_oid_or_dash>, user=<role_name>, success=<t|f>, pid=<pid>
oid— relation OID affected by the event, or-for cluster-level events.success—ton success,fon failure (e.g. authentication error, GCM tag mismatch).
Logged events
The event= field carries the value below (note the DEK/KEK events drop the KMS_ prefix in the log line):
event= value |
Trigger | PCI DSS ref |
|---|---|---|
AUDIT_LOG_START |
Audit subsystem initialised at server start | 10.2.1.6 |
AUDIT_LOG_STOP |
Audit subsystem shut down | 10.2.1.6 |
DEK_ACCESS |
DEK read from shared-memory cache or KMS | — |
DEK_CREATE |
New per-relation DEK generated | — |
DEK_ROTATE |
Per-relation DEK rotated (pg_vault_tde_rotate_online) |
10.2.1.7 |
DEK_DELETE |
DEK revoked / removed from catalog (DROP TABLE) |
10.2.1.7 |
KEK_ROTATE |
KEK rotated (pg_vault_tde_rotate_kek) |
10.2.1.7 |
KMS_AUTH_SUCCESS |
KMS / Vault authentication succeeded | 10.2.1.5 |
KMS_AUTH_FAILURE |
KMS / Vault authentication failed | 10.2.1.5 |
WALLET_OPEN |
Local wallet opened (pg_vault_tde_wallet_unlock) |
— |
WALLET_CLOSE |
Local wallet closed (pg_vault_tde_wallet_lock) |
— |
RELATION_ENCRYPT |
Relation converted to encrypted_heap |
10.2.1.7 |
RELATION_DECRYPT |
encrypted_heap converted back to plain heap |
10.2.1.7 |
ACCESS_DENIED |
Decryption failed — wrong key or missing permission | 10.2.1.4 |
DEK_UPDATE and INTEGRITY_VIOLATION are defined in the audit enum but not yet
emitted by any code path (reserved for a future release).
Routing audit logs
Because audit records are written as PostgreSQL LOG messages they flow through
the standard log_destination / logging_collector pipeline. To route them to
a dedicated file or to an external SIEM, match on the AUDIT: prefix:
# postgresql.conf — route AUDIT lines to a separate file (requires logging_collector = on)
log_destination = 'stderr'
logging_collector = on
log_filename = 'postgresql-%Y-%m-%d.log'
External sinks (syslog, Splunk, Datadog) can filter on AUDIT: from the standard
log stream without any extension-level configuration.
SQL Functions
| Function | Returns | Description |
|---|---|---|
pg_vault_tde_health_check() |
composite | Status (6 columns: version, build_version, enabled, kms_provider, enc_ops_available, checked_at) |
pg_vault_tde_verify_integrity(regclass) |
record | GCM tag audit scan of all tuples and every out-of-line value they reference — returns (total_tuples, failed_tuples), a row counted once whichever part failed. Up to 1.7.1 it could count intact rows as failed when the table was invalidated during its scan (autovacuum updating its statistics): on 1.7.1, run it again before acting on a failure. Requires SELECT on the table (v1.7.2; up to 1.7.1 any role could call it on any table) |
pg_vault_tde_encrypted_size(regclass) |
record | Encryption storage overhead — returns (total_tuples, encryption_overhead_bytes). Requires SELECT on the table (v1.7.2; up to 1.7.1 any role could call it on any table) |
pg_vault_tde_reencrypt_table(regclass, int) |
void | Batch re-encrypt with current DEK (locks table); int = batch size, default 1000. Requires MAINTAIN on the table (its owner, pg_maintain, superusers) since 1.7.2 — see Who may call reencrypt_table() |
pg_vault_tde_rotate_online(regclass, int) |
void | BGW-based online rotation: reads continue, writes wait until it commits; accepts both encrypted_heap tables and tde_btree indexes (v1.5) |
pg_vault_tde_get_rotation_status(regclass) |
table | Online rotation progress for one relation (status, tuples_done/total, pct_complete, timestamps) (v1.5) |
pg_vault_tde_rotation_status |
view | All in-progress/completed rotations across the cluster; readable by pg_monitor (v1.5) |
pg_vault_tde_check_plaintext_index_keys() |
table | Meant to list tde_btree indexes on a plaintext-key operator class. Known defect: returns no rows in 1.7.x, and its REINDEX suggestion would not change the operator class. Use the query in Upgrading to 1.7.2 instead; replaced in 1.8. pg_monitor/superuser only |
pg_vault_tde_wallet_init(text) |
void | Create local wallet and generate KEK (v1.5); refused while keys of the database are wrapped under a wallet that is missing. Superuser only — the calling role, see Who may call the key-management functions |
pg_vault_tde_wallet_change_passphrase(text, text) |
void | Re-protect wallet with new passphrase and automatically rotate the KEK (local provider only); no separate rotate_kek() needed. Since 1.7.2 the new passphrase is in effect as soon as the wallet file is rewritten, even if the call then fails (v1.6) |
pg_vault_tde_wallet_status() |
composite | Wallet existence, open state, algorithm, last opened, file perms (5 cols) (v1.6) |
pg_vault_tde_wallet_unlock(text) |
void | Interactive wallet unlock without PG restart (v1.6) |
pg_vault_tde_wallet_lock() |
void | Evict all DEKs from shmem, mark wallet closed (v1.6) |
pg_vault_tde_rotate_kek() |
void | Rotate the KEK and re-wrap all per-table DEKs under a new key; works for the local, vault, and pkcs11 providers; no tuple data re-encrypted (v1.7) |
pg_vault_tde_pkcs11_keygen() |
void | One-time AES-256 KEK provisioning on the PKCS#11 token under pkcs11_key_label; refuses to overwrite an existing key (v1.7) |
pg_vault_tde_seal_keys(text, text, text) |
void | Write an HMAC-SHA256-signed bundle of all wrapped DEKs (every provider) to a file, to accompany a physical backup (pg_basebackup); the KEK is never included (v1.7) |
pg_vault_tde_seal_keys_bytea(text, text) |
bytea | Same signed bundle as pg_vault_tde_seal_keys(), returned as bytea instead of written server-side — used by pg_basebackup_tde to store the bundle on the client host (v1.7) |
pg_vault_tde_unseal_keys(text, text) |
void | Verify (HMAC) and re-import a bundle written by pg_vault_tde_seal_keys(); rejects a tampered file or wrong passphrase before writing anything (v1.7) |
pg_vault_tde_migrate_vault_to_wallet(text) |
void | Online Vault→local wallet migration: pass the passphrase of the wallet created with wallet_init(); re-wraps every Vault DEK under the wallet’s KEK and switches the database to the local provider. Broken before 1.7.2 — see Upgrading to 1.7.2 (v1.6) |
pg_vault_tde_vault_status() |
table | Vault provider diagnostics — (configured, auth_method, reachable). Superuser only (v1.7.2; up to 1.7.1 open to any role) |
pg_vault_tde_refresh_token() |
boolean | Manually renew the current Vault token lease. Superuser only (v1.7.2; up to 1.7.1 open to any role) |
pg_vault_tde_hw_accel_info() |
record | OpenSSL provider/cipher diagnostics — (openssl_version, configured_provider, provider_loaded, gcm_cipher, siv_cipher, aes_ni_available) |
Access Methods
| Name | Type | Purpose |
|---|---|---|
encrypted_heap |
TABLE | Encrypts all user-data columns of every stored tuple |
tde_btree |
INDEX | AES-256-SIV deterministic encryption for B-Tree index keys |
-- Table with encrypted heap storage
CREATE TABLE secrets (id serial, token text) USING encrypted_heap;
-- B-Tree index with deterministic key encryption
CREATE INDEX ON secrets USING tde_btree (id);
Upgrading to 1.7.1
1.7.1 fixes ALTER TABLE ... SET ACCESS METHOD encrypted_heap on a populated
table by binding the AEAD tag to the relation’s effective OID — the same OID
the DEK and generation counter were already looked up under. For a TOAST
relation that effective OID is the parent table’s, where releases up to 1.7.0
used the TOAST relation’s own OID.
The AAD is never written to disk, so the reader has to reproduce the writer’s derivation exactly. Out-of-line TOAST values written by 1.7.0 or earlier therefore do not authenticate under 1.7.1. The two schemes cannot coexist: during a table rewrite the transient TOAST relation gets a fresh OID, so the parent hop is what makes the ALTER work in the first place.
What is and is not affected (verified by writing under 1.7.0 and reading back under 1.7.1 on the same data directory):
| Under 1.7.1 | |
|---|---|
encrypted_heap tables with no TOAST data |
✅ readable, byte-identical |
| Inline values (below the ≈2 kB TOAST threshold) | ✅ readable, byte-identical |
| Non-TOASTed columns of a table that has TOAST data | ✅ readable |
| Out-of-line TOAST values | ❌ AES-256-GCM authentication FAILED |
pg_dump of an affected table |
❌ exits 1 |
Nothing is lost: the ciphertext on disk is untouched, and reinstalling 1.7.0
makes it readable again. But pg_dump stops working after the upgrade, so
the export has to come first.
Step 1 — while still on 1.7.0, find the affected tables
SELECT c.oid::regclass AS table_to_export,
pg_size_pretty(pg_relation_size(c.reltoastrelid)) AS toast_size
FROM pg_class c
JOIN pg_am a ON a.oid = c.relam
WHERE a.amname = 'encrypted_heap'
AND c.reltoastrelid <> 0
AND pg_relation_size(c.reltoastrelid) > 0;
No rows means nothing to do — install 1.7.1 and carry on.
A table whose TOAST table was created while pg_vault_tde.toast_encryption was
off could not read its out-of-line values before the upgrade either (PSQLE-223):
its chunks were encrypted but its TOAST table was a plain heap one.
Step 2 — dump those tables, still on 1.7.0
pg_dump -U postgres -d yourdb -t schema.affected_table --data-only \
-f affected_table.sql
Step 3 — install 1.7.1, then truncate and restore
psql -U postgres -d yourdb -c 'TRUNCATE schema.affected_table;'
psql -U postgres -d yourdb -f affected_table.sql
Confirm the running binary with SELECT pg_vault_tde_build_version(); — it
reports 1.7.1 while pg_extension.extversion stays at 1.7, because 1.7.1
ships no SQL changes.
If you upgraded first
You will get:
ERROR: [CRYPTO] AES-256-GCM authentication FAILED: data integrity violation or wrong DEK
DETAIL: The AEAD tag for relation 16541 is bound to relation 16537.
HINT: If this data was written by pg_vault_tde 1.7.0 or earlier it is not corrupt: ...
This is not corruption and not a key problem. Reinstall the 1.7.0 package,
verify with pg_vault_tde_build_version(), then start from Step 1.
Also check your PostgreSQL minor
Unrelated to 1.7.1, but it lands on the same people: PostgreSQL 17.11 / 18.x
and newer refuse to load this extension’s logical decoding output plugin unless
it is listed in output_plugin_libraries. If you replicate encrypted tables,
see Logical replication on PostgreSQL 17.11 / 18.x and
newer below.
Upgrading to 1.7.2
1.7.2 changes the on-disk tuple layout (v5). Nothing has to be exported
first — every row written by 1.7.0 or 1.7.1 keeps reading, byte for byte — but
each encrypted table needs one VACUUM FULL after the upgrade, and until it
has had one, UPDATE on some of its rows can crash the backend. Run key rotations
and wallet operations one at a time, before and after the upgrade alike — see
One key operation at a time.
Why
The encrypted region used to be one opaque blob, while the tuple header — which
stays in plaintext, because MVCC and VACUUM need it — went on declaring that the
row held natts attributes laid out per the table’s tuple descriptor. Every core
path that reads a raw on-disk tuple believes that header, and heap_update()
does it on every UPDATE: it reads the indexed attributes straight off the
page to decide whether the update can be HOT and which indexes to maintain.
Past the first variable-length column an attribute’s offset is not cached, so
reaching it means walking the row — and the walk was reading varlena length
headers out of ciphertext. A four-byte header of random bytes yields a length of
up to 1 GB, the cursor leaves the page, and the backend dies with SIGSEGV.
Any index on such a column is enough, tde_btree included: what matters is that
the column is indexed, not which access method indexes it.
v5 keeps the row physically valid — every attribute at its own offset with its own length, only the values replaced by ciphertext — so that walk is safe. The AEAD is unchanged: same cipher, same tag, same AAD, and the same 37 bytes of overhead per tuple.
What to run
Rows written before the upgrade keep the old layout until something rewrites
them, and no layout can be made walkable after the fact. VACUUM FULL (or
CLUSTER) rewrites every row through the extension and migrates the table:
-- every encrypted table in the current database
SELECT format('VACUUM FULL %s;', c.oid::regclass)
FROM pg_class c
JOIN pg_am a ON a.oid = c.relam
WHERE a.amname = 'encrypted_heap'
AND c.relkind = 'r';
Run the statements it prints. VACUUM FULL takes an ACCESS EXCLUSIVE lock and
needs room for a second copy of the table, so treat it as a maintenance window.
Verified to leave the data byte-identical (make ci-upgrade).
Tables created after the upgrade are in v5 from the first row and need nothing.
Also fixed: an all-NULL row made its table unreadable
Independent of the layout change, and present in every release up to 1.7.1: a row whose columns are all NULL has no user data at all, so its encrypted region is the AEAD framing and nothing else. That is a well-formed encoding of a zero-length plaintext, and the decrypt path rejected it:
ERROR: [CRYPTO] Ciphertext too short for AES-256-GCM
One such row is enough to make the whole table fail on any sequential scan from
that INSERT onwards. The row is not corrupt — the ciphertext on disk is fine —
and 1.7.2 reads it without any migration step. If you have hit this, upgrading is
the whole fix.
Custom WAL resource manager id: 128 → 161
1.7.2 moves the custom WAL resource manager from RM_EXPERIMENTAL_ID (128) — the
id upstream reserves for experimentation, and so the one every prototype uses — to
161, registered for pg_vault_tde on the PostgreSQL Custom WAL Resource
Managers wiki. That is what keeps pg_vault_tde from colliding with another
extension loaded in the same cluster.
If pg_vault_tde.toast_custom_rmgr is off — the default — there is nothing to
do. No WAL record is ever written under the extension’s id, so none carries 128.
If it is on, WAL written by 1.7.1 carries id 128, which 1.7.2 no longer knows:
replaying it fails with resource manager with ID 128 not registered, which is
fatal in the startup process. WAL carries the number, not the name, so this is not
something a restart can work around. Before switching binaries:
- stop the primary with a clean shutdown (
pg_ctl stop -m fastorsmart, neverimmediate), so nothing is left to replay on the next start; - let every physical standby replay up to that shutdown checkpoint, then stop and upgrade it together with the primary. No rolling upgrade: a 1.7.2 standby cannot replay a 1.7.1 primary’s records, and a 1.7.1 standby cannot replay a 1.7.2 primary’s;
- drain every logical replication slot that decodes TOAST through the custom resource manager;
- take a new base backup after the upgrade if you keep a WAL archive for PITR. Recovering into the pre-upgrade window needs the 1.7.1 binaries.
Whatever the GUC says, check the id before upgrading. 1.7.1 claimed 128, so a cluster running it alongside an extension that uses 161 worked; with 1.7.2 the same cluster will not start. Run the check in 2. Configure PostgreSQL on every node first — it must return only pg_vault_tde’s own row, under 128 while 1.7.1 is still running, and nothing else.
Renamed shared-memory objects
The DEK cache’s shared hash table and its LWLock tranche were both called
TdeRelDekMap. Both names live in cluster-wide namespaces shared with every other
preloaded extension, and PostgreSQL reports a clash in neither: a second extension
using the same name would silently share the lock, or attach to the existing hash
table and read it through its own layout. In 1.7.2 both are
pg_vault_tde_rel_dek_map, prefixed like the extension’s other shared-memory
objects.
Nothing on disk changes — shared memory is rebuilt at every start. Update only
monitoring that matches the old name in pg_stat_activity.wait_event or in
pg_shmem_allocations.name.
tde_btree answers equality only
Up to 1.7.1 the planner used tde_btree indexes for range predicates, ORDER BY,
min()/max() and merge joins, reading them in ciphertext order: on text, bytea and
numeric columns those queries returned wrong rows without any error. IN (…) failed
with cache lookup failed for type …, and on numeric even = missed rows. 1.7.2
enforces the one question AES-SIV can answer — see Limitation 2.
Nothing on disk changes. What you may notice:
- Queries that range-filter or sort an indexed column run as sequential scans. They are correct now, and may be slower than the wrong answer was.
IN (…)and= ANY (…)work, through bitmap index scans.numericand nondeterministic-collationtde_btreeindexes can no longer be created, by any path:CREATE INDEX, anEXCLUDEconstraint, or the rebuild behindALTER COLUMN … TYPE— so changing a column tonumericnow fails while it has atde_btreeindex; drop the index first. Existing ones are ignored by the planner, so queries are right, but aUNIQUEorEXCLUDEconstraint on one is not enforced: the check reads the index directly, misses equal values and lets duplicates in. Find them and drop them:
SELECT DISTINCT ix.indexrelid::regclass AS index_name,
ix.indrelid::regclass AS table_name,
ix.indisunique,
con.conname AS constraint_name
FROM pg_index ix
JOIN pg_class ic ON ic.oid = ix.indexrelid
JOIN pg_am am ON am.oid = ic.relam AND am.amname = 'tde_btree'
CROSS JOIN LATERAL unnest(ix.indclass::oid[], ix.indcollation::oid[]) AS k(opc, coll)
JOIN pg_opclass opc ON opc.oid = k.opc
LEFT JOIN pg_collation c ON c.oid = k.coll
LEFT JOIN pg_constraint con ON con.conindid = ix.indexrelid
WHERE opc.opcintype = 'numeric'::regtype OR c.collisdeterministic IS FALSE
ORDER BY 1;
REINDEX, CONCURRENTLY included, still rebuilds them. A restore of a dump containing
one reports an error for that index and restores everything else.
- The v1.5 operator classes that keep keys in plaintext — tde_int4_ops,
tde_int8_ops, tde_uuid_ops, tde_date_ops, tde_timestamptz_ops — are refused for
new indexes unless pg_vault_tde.allow_plaintext_index = on, which a restore of a dump
that names one also needs. Indexes already built on them keep working. This lists them,
each with the statements that rebuild it on the encrypted default:
SELECT ix.indexrelid::regclass AS index_name,
ix.indrelid::regclass AS table_name,
regexp_replace(
regexp_replace(pg_get_indexdef(ix.indexrelid),
' tde_(int4|int8|uuid|date|timestamptz)_ops\M', '', 'g'),
'^CREATE (UNIQUE )?INDEX (\S+) ON ', 'CREATE \1INDEX CONCURRENTLY \2_enc ON ')
|| '; DROP INDEX CONCURRENTLY ' || ix.indexrelid::regclass || ';' AS migrate
FROM pg_index ix
WHERE EXISTS (
SELECT 1 FROM pg_opclass opc
WHERE opc.oid = ANY (ix.indclass::oid[])
AND opc.opcmethod = (SELECT oid FROM pg_am WHERE amname = 'tde_btree')
AND opc.opcname IN ('tde_int4_ops', 'tde_int8_ops', 'tde_uuid_ops',
'tde_date_ops', 'tde_timestamptz_ops'))
ORDER BY 1;
Run the migrate statements one at a time: CREATE INDEX CONCURRENTLY cannot run in a
transaction block. pg_vault_tde_check_plaintext_index_keys() would be the obvious tool,
and returns no rows in 1.7.x — a known defect, replaced in 1.8.
KEK versions in the local wallet
Up to 1.7.1, pg_vault_tde_rotate_kek() and pg_vault_tde_wallet_change_passphrase()
on the local wallet replaced the file’s only KEK before their transaction
committed. A rotation that rolled back, failed later in the same statement, or died
in a crash left every table of the database wrapped under a KEK that no longer
existed. A session that had run wallet_unlock() also kept the old KEK in its own
memory, could not read after another session’s rotation, and wrapped the DEK of any
table it created with the old key — lost at the next restart (PSQLE-185).
1.7.2 keeps every KEK version in the wallet: one PKCS#12 key bag per version, the
current one first. A rotation adds a version, durably, before it re-wraps anything,
and removes none. A session holding an older copy reloads the wallet from its
passphrase source when it meets a newer version, or asks for a new wallet_unlock()
if it has none. The wrapped DEKs in pg_vault_tde_catalog are unchanged.
What to know:
- Nothing to do before upgrading. A 1.7.1 wallet is read as version 1; the first rotation on 1.7.2 writes the new layout.
- Downgrading after a rotation on 1.7.2: 1.7.1 reads the first — current — KEK of the file, which is every DEK’s after a rotation that committed. After one that did not, some DEKs are under an older version that 1.7.1 cannot use.
- Dumps taken before a KEK rotation restore again with the 1.7.2
pg_restore_tde: their DEK is under an older version, which the wallet now keeps. - A session unlocked only by
wallet_unlock(), with no passphrase source, has to unlock again after another session rotates the KEK or changes the passphrase. - If a rotation on 1.7.1 did not commit and your tables stopped reading, restore the wallet file from before that rotation: the catalog is still wrapped under it.
migrate_vault_to_wallet() before 1.7.2
Up to 1.7.1, pg_vault_tde_migrate_vault_to_wallet() wrapped every DEK under a key
derived from its passphrase argument instead of the KEK held by the wallet it was told
to use, accepted any passphrase, and left the database on the Vault provider. Every
migrated table became unreadable, and the Vault-wrapped copy of each DEK had been
overwritten in pg_vault_tde_catalog (PSQLE-188).
1.7.2 opens the wallet with the passphrase — a wrong one is refused before anything
changes — wraps under the wallet’s current KEK, keeps the cached keys so sessions keep
reading, and switches the database to kms_provider = 'local'. Sessions connected
before the migration keep the Vault provider until they reconnect.
If you ran it on 1.7.1: the tables it migrated can only come back from a backup
taken before the migration — of the database, or of pg_vault_tde_catalog while the
Vault Transit key still exists.
rotate_online() with concurrent access
Up to 1.7.1, a pg_vault_tde_rotate_online() that ran while its table was read or
written could leave rows encrypted under a key that existed only in shared memory: some
became unreadable at once, the rest at the next restart. A single SELECT during the
rotation was enough to lose the whole table at the restart, and
pg_vault_tde_verify_integrity() reports nothing wrong until then (PSQLE-184).
Before the restart that installs 1.7.2: if rotate_online() has run since the last
restart on a table that was being read or written, copy that table while the server is
still up (CREATE TABLE … AS SELECT … or pg_dump). The restart discards the only copy
of the key; after it, only a backup brings the rows back.
What changes in 1.7.2: SELECT keeps working during a rotation, while INSERT,
UPDATE, DELETE, COPY and a second rotation of the same table wait for it to
commit. The whole table is re-encrypted in one transaction, so on a large table treat a
rotation as a window with no writes.
rotate_online() and out-of-line values
Up to 1.7.1, pg_vault_tde_rotate_online() re-encrypted every row but left its
out-of-line (TOAST) values — typically text, bytea or jsonb values still over
about 2 kB after compression — under the outgoing key. The catalog keeps only the
current key, so the outgoing one survived in shared memory alone: the values became
unreadable at the next restart, or at once at the next rotation of the same table. No
concurrent access is needed. The rest of each row stays readable, a DELETE of an
affected row fails, and up to 1.7.1 pg_vault_tde_verify_integrity() reports nothing
wrong: it checked the rows, not their TOAST values (PSQLE-189, PSQLE-196). In 1.7.2 it
counts such a row as failed.
Before the restart that installs 1.7.2: run VACUUM FULL on every table with
out-of-line values that has been rotated once since the last restart. It rewrites the
values under the table’s current key while the outgoing one is still in shared memory;
UPDATE t SET col = col || '' on each affected column does the same. This lists the
candidates:
SELECT p.relid::regclass AS table_name, p.updated_at AS rotated_at
FROM pg_vault_tde_rotation_progress p
JOIN pg_class c ON c.oid = p.relid
WHERE p.status = 'complete'
AND p.updated_at > pg_postmaster_start_time()
AND c.reltoastrelid <> 0
AND pg_relation_size(c.reltoastrelid) > 0;
A table rotated twice since the restart, or rotated before it, has already lost the
values stored before its last rotation, and only a backup brings them back.
SELECT sum(length(t::text)) FROM t reads every value of t and fails with
decryption failed if any of them is lost. A missing chunk number 0 from the same
query is a different, harmless defect — see Dropped columns and out-of-line values.
What changes in 1.7.2: the rotation rewrites every out-of-line value under the new key
and deletes the old chunks; dropped columns become NULL, as in any UPDATE, and their
chunks go too. A rotation of a table with large
values reads and writes all of them, so it takes longer and writes more WAL than in
1.7.1.
Concurrent UPDATE of out-of-line values
Up to 1.7.1, an UPDATE that replaced an out-of-line value deleted the old value’s
chunks before it found out whether another transaction had changed the row. When one
had — a concurrent UPDATE or DELETE of the same row, under READ COMMITTED — the
waiting UPDATE could (PSQLE-193):
- skip the row, because its
WHEREno longer matched the newer version, which still pointed at the deleted chunks: the value kept reading until the nextVACUUM, then failed withmissing chunk number 0 for toast value …; - fail with
tuple concurrently deletedwhen the other transaction had replaced or deleted the same value; - go ahead on the newer version and leave the chunks of the values it did not change behind, referenced by nothing.
A value already lost cannot be brought back except from a backup;
SELECT sum(length(t::text)) FROM t fails on a table that has one. The orphaned
chunks only take space, and VACUUM FULL drops them. In 1.7.2 the old chunks are
deleted only once the row has been updated, and an attempt that finds the row changed
removes the chunks it had written, so the waiting UPDATE behaves as on a plain heap
table.
The same change fixes a failure that needed no concurrency: an UPDATE turning an
out-of-line value into a compressed inline one — SET col = repeat('x', 6000) over a
value stored out of line — failed with tuple already updated by self (PSQLE-191). No
data was affected; the statement rolled back.
An INSERT ... ON CONFLICT that lost the race to a concurrent insert of the same key
also left its out-of-line values behind, referenced by nothing (PSQLE-197). They only
take space; VACUUM FULL drops them, and 1.7.2 no longer leaves them.
Dropped columns and out-of-line values
A column dropped with ALTER TABLE ... DROP COLUMN keeps its values in every row until
the row is rewritten. Up to 1.7.1 the TAM mishandled the out-of-line ones (PSQLE-192):
DELETEleft their chunks in the TOAST relation, referenced by nothing, whenever the row had no other out-of-line value — and so did aDELETEthat waited on a concurrentUPDATEof the row, for every value of the row;VACUUM FULLandCLUSTERcopied the dropped column’s pointer as it was into the rewritten table, where it points into the TOAST relation the rewrite replaced. The live columns read normally, but a read of the whole row —SELECT t FROM t,t::text,row_to_json(t)— fails withmissing chunk number 0 for toast value …;- with the 1.7.2 fix for out-of-line values in a rotation
alone, the same pointers made
rotate_online()fail; that combination never shipped.
After installing 1.7.2: the VACUUM FULL that Upgrading to 1.7.2
already asks for repairs all of it: it rewrites dropped columns as NULL, as core does,
which removes the dangling pointers and the orphaned chunks. A rotation or an UPDATE
of a row repairs that row too, and rotate_online() and DELETE work on the tables
as they are. make ci-upgrade checks each of these on data written by 1.7.1.
Streaming standby and rotate_online()
The rotation moves the primary’s shared-memory DEK cache to the new key when it commits; a streaming standby learns of it only from the replicated catalog row. Up to 1.7.1 a standby that had a table’s DEK cached kept the retired one (PSQLE-190):
- every row of the new generation went through the catalog and the KMS — one unwrap per row, an HTTP call each with Vault — until the standby restarted;
- after a promotion the node encrypted new rows of such a table with the retired key. In 1.7.1 the table stops reading at once, at the first scan that meets a new row; with the rest of 1.7.2’s fixes alone the rows would have been lost at the next restart.
Still on 1.7.1: after promoting a standby, restart it before the first write —
with the local wallet, pg_vault_tde_wallet_unlock() in each database does the same.
Both empty the cache, so every key comes from the catalog again.
What changes in 1.7.2: a catalog read that shows a newer generation than the cached
one replaces it, so the standby unwraps once per rotated table rather than once per
row; and once a node leaves recovery, the first write to each table checks the key it
cached during recovery against the catalog. tap/34_standby_rotation.t covers both,
including a table first touched by an INSERT after the promotion.
rotate_online() and indexes
The rotation rewrites every row, and a rewrite that cannot stay on its page puts the
new version elsewhere; an UPDATE would then add an entry for it to every index. Up to
1.7.1 the rotation added none (PSQLE-194). It rebuilt the table’s tde_btree indexes
and left every other one pointing at the retired row versions only:
- after the rotation an index scan through them finds nothing — including lookups by
PRIMARY KEYor through aUNIQUEconstraint, which on an encrypted table are standard btree indexes by default; PRIMARY KEYandUNIQUEno longer hold: a duplicate is accepted.
Sequential scans still return every row, so nothing is lost, but queries that use those indexes return wrong results.
After installing 1.7.2 (or right away on 1.7.1): run REINDEX TABLE on every
table pg_vault_tde_rotate_online() or pg_vault_tde_reencrypt_table() has ever
rewritten. The online rotations are listed by
SELECT relid::regclass FROM pg_vault_tde_rotation_progress WHERE status = 'complete'.
If duplicates got in meanwhile, REINDEX of the unique index fails and names the
key: remove the extra rows first (SELECT id, count(*) FROM t GROUP BY id HAVING
count(*) > 1 for a key id).
What changes in 1.7.2: the rotation inserts the index entries of every row it
rewrites, as the executor’s UPDATE does — partial and expression indexes and
uniqueness checks included — and still rebuilds the tde_btree ones. The new entries
take no lock beyond the rotation’s own; only the tde_btree rebuild at the end locks
its index. tap/35_rotate_online_indexes.t.
Partial indexes on encrypted tables
CREATE INDEX and REINDEX on an encrypted table run the extension’s own build scan,
which decrypts each row before computing its keys. Up to 1.7.1 it never evaluated the
index predicate, so a partial index (CREATE INDEX ... WHERE ...) received every row
(PSQLE-198):
- queries through it can return wrong rows. The planner drops the conditions the
predicate implies, trusting the index to hold only rows that satisfy it: with an
index on
(customer) WHERE status = 'open',WHERE customer = 7 AND status = 'open'returned every order of customer 7; - a valid
CREATE UNIQUE INDEX ... WHERE ...was refused when the key repeated outside the predicate; - partial indexes were as large as full ones.
After installing 1.7.2: REINDEX every partial index on an encrypted table. This
lists them:
SELECT ix.indexrelid::regclass AS index_name, ix.indrelid::regclass AS table_name
FROM pg_index ix
JOIN pg_class t ON t.oid = ix.indrelid
JOIN pg_am a ON a.oid = t.relam AND a.amname = 'encrypted_heap'
WHERE ix.indpred IS NOT NULL
ORDER BY 1;
Until it is rebuilt, a partial index can answer wrongly; REINDEX INDEX CONCURRENTLY
rebuilds it without blocking writes. make ci-upgrade builds one with 1.7.1 and checks
that this query finds it and that REINDEX makes it answer what a sequential scan
answers.
Indexes built while an older snapshot is open
PostgreSQL builds an index so that transactions already running when it was built can
use it too: it also indexes row versions that are dead for everyone else but may still
be visible to them, and when a HOT chain changed the indexed column it marks the index
unusable for those older transactions (pg_index.indcheckxmin). Up to 1.7.1 the
extension’s own build scan did neither: it indexed only what a fresh snapshot saw
(PSQLE-201). A REPEATABLE READ or SERIALIZABLE transaction that started before a
CREATE INDEX and then queried through the new index missed rows deleted or updated
after its snapshot, and could get rows whose version visible to it does not satisfy
the query.
Only those older transactions were affected, and only until they ended: for every transaction started after the build, an index built by 1.7.1 is correct. Nothing needs to be done after installing 1.7.2, which builds indexes as heapam does. If two rotations of the table left row versions an old snapshot can still see under a key that no longer exists, the index is built without them and marked unusable for older transactions, as PostgreSQL does for broken HOT chains.
CLUSTER on encrypted tables
Up to 1.7.1, CLUSTER t USING idx on an encrypted table compacted it and marked the
index clustered, but left the rows in the order they were in (PSQLE-204): the extension
rewrote the table with its own sequential copy and ignored the index. 1.7.2 orders the
rows as PostgreSQL does, through an index scan or a sort, whichever the planner picks.
A tde_btree index is ordered by the ciphertext of its keys, not by their values, so
CLUSTER on one is refused with an error: use VACUUM FULL to compact the table, or
cluster on a plain btree index — the PRIMARY KEY and UNIQUE constraints of an
encrypted table are plain btree indexes by default. A table marked clustered on a
tde_btree index by an earlier release makes a plain CLUSTER t fail the same way;
ALTER TABLE t SET WITHOUT CLUSTER clears the mark. A sort that outgrows
maintenance_work_mem spills decrypted rows to temporary files, as any sort does — see
the note on temp_tablespaces above.
Who may call reencrypt_table()
pg_vault_tde_reencrypt_table() rewrites every row of a table: it takes locks, writes
WAL and leaves the old versions behind until VACUUM. Up to 1.7.1 the extension granted
EXECUTE on it to pg_monitor — a role meant for monitoring — and checked nothing
else, so any member of it could rewrite any encrypted table, including tables it had no
privilege on (PSQLE-205). 1.7.2 also requires the caller to hold MAINTAIN on the
table, as VACUUM FULL, CLUSTER and REINDEX do: the table’s owner, members of
pg_maintain and superusers hold it. Granting EXECUTE to another role no longer
lets that role rewrite tables it may not maintain.
Still on 1.7.1: take the function away from pg_monitor, as a superuser:
REVOKE EXECUTE ON FUNCTION pg_vault_tde_reencrypt_table(regclass, int),
pg_vault_tde_reencrypt_table(text, int) FROM pg_monitor;
1.8 removes the grant from the extension script.
Since 1.7.2 the function also refuses to run inside a security-restricted
operation. ANALYZE, VACUUM and REINDEX run as the table’s owner, and so does
any index expression they evaluate; the role the check asks about — the session role —
is not that owner’s, so a table owner’s code evaluated during a superuser’s maintenance
would have been checked against the superuser (PSQLE-225). A rewrite has no business
inside an index expression, so it is refused there outright.
Who may call the key-management functions
wallet_init, wallet_unlock, wallet_lock, wallet_change_passphrase,
migrate_vault_to_wallet, seal_keys, seal_keys_bytea, unseal_keys and
rotate_kek are SECURITY DEFINER, and each checked superuser() — which inside such
a function asks about its owner, the superuser who ran CREATE EXTENSION, and is
always true (PSQLE-206). Only REVOKE ... FROM PUBLIC kept them closed, and
wallet_init() is granted to pg_monitor: up to 1.7.1 any member of it could create
the wallet of a database that had none, with a passphrase of its own choosing, and so
hold the KEK of every table later encrypted there. pkcs11_keygen() checked nothing.
1.7.2 checks the role that called the function, whoever has been granted EXECUTE:
all of them require a superuser. Delegating a database’s wallet to its owner or to a
tenant role is planned for 1.8.
They also refuse inside a security-restricted operation — ANALYZE, VACUUM,
REINDEX and the index expressions they evaluate, which run as the table’s owner —
because the session role the check asks about is not the caller there (PSQLE-225).
One gap remains until 1.8: a role that holds EXECUTE can still reach these functions
through code of its own that a superuser runs, such as a SECURITY DEFINER function it
owns. The check cannot see past its own SECURITY DEFINER wrapper to that role; 1.8
removes both the wrapper and the pg_monitor grants (the same REVOKE above is the
mitigation meanwhile). Treat calling another role’s function as a superuser the way
PostgreSQL does in general: as running that role’s code.
Still on 1.7.1: take wallet_init() away from pg_monitor, as a superuser —
REVOKE EXECUTE ON FUNCTION pg_vault_tde_wallet_init(text) FROM pg_monitor; — and
check that every database with a wallet got it from a superuser. The wallets are the
directories under /var/lib/pg_vault_tde/, one per database OID; each creation left a
pg_vault_tde: wallet initialized at "…" line in the server log, which names the role
when log_line_prefix includes %u — otherwise match its time and PID against the
connection log (log_connections). If a wallet may have been created by someone else,
change its passphrase (pg_vault_tde_wallet_change_passphrase()) and rotate the KEK
(pg_vault_tde_rotate_kek()).
Who may call the read-only functions
pg_vault_tde_verify_integrity() and pg_vault_tde_encrypted_size() open, scan and
decrypt a whole relation; pg_vault_tde_vault_status() reports the server’s KMS
configuration and probes Vault, and pg_vault_tde_refresh_token() makes it renew its
Vault lease. All four keep EXECUTE to PUBLIC and checked nothing up to 1.7.1, so any
role could ask them about a table it may not read, or act on the cluster’s KMS
(PSQLE-226).
1.7.2 checks the role whose code is running: the two scans require SELECT on the
relation — what a count(*) over it would need — and the two KMS functions require a
superuser. Nothing else changes, and a superuser is unaffected.
If you monitor with them: a pg_monitor membership is not SELECT. Give the
monitoring role SELECT on the tables it checks, or pg_read_all_data for all of them;
move vault_status() and refresh_token() to a superuser connection.
Tables created with toast_encryption = off
Up to 1.7.1, pg_vault_tde.toast_encryption = off did not store TOAST in plaintext, as
documented. A table created while it was off — or rewritten by VACUUM FULL, CLUSTER
or SET ACCESS METHOD — got a plain heap TOAST table whose chunks were still
encrypted, and read them back undecrypted: every out-of-line value of the table fails
with unexpected chunk number … for toast value …, and so does a VACUUM FULL or
CLUSTER of it (PSQLE-223). 1.7.2 always gives an encrypted table an encrypted_heap
TOAST table; the setting has no effect and only raises a WARNING.
The values are not lost: the chunks are encrypted with the table’s key, and only the access method of its TOAST table is wrong. In each database, as a superuser:
- Find the tables:
SELECT c.oid::regclass AS table_name, t.oid::regclass AS toast_table
FROM pg_class c
JOIN pg_am ca ON ca.oid = c.relam
JOIN pg_class t ON t.oid = c.reltoastrelid
JOIN pg_am ta ON ta.oid = t.relam
WHERE ca.amname = 'encrypted_heap' AND ta.amname <> 'encrypted_heap';
- Check that the table is affected — reading every value of every row fails:
SELECT sum(length(t::text)) FROM <table_name> t;
If this succeeds, the table is not affected (its TOAST chunks are plaintext, from a release before 1.6): leave it as it is.
- Give its TOAST table the
encrypted_heapaccess method:
UPDATE pg_class
SET relam = (SELECT oid FROM pg_am WHERE amname = 'encrypted_heap')
WHERE oid = '<toast_table>'::regclass;
- Check: the query of step 2 now succeeds, and
SELECT * FROM pg_vault_tde_verify_integrity('<table_name>')reportsfailed_tuples = 0.
Compatibility
| Feature | Status | Notes |
|---|---|---|
| Sequential scan | ✅ Full | scan_getnextslot override |
| Index scan | ✅ Full | index_fetch_tuple override + rd_tableam impersonation |
| Bitmap heap scan | ✅ Full | scan_bitmap_next_tuple override |
| ANALYZE | ✅ Full | scan_analyze_next_tuple override |
| TABLESAMPLE | ✅ Full | scan_sample_next_tuple override |
| SELECT FOR UPDATE | ✅ Full | tuple_lock override |
| INSERT / COPY | ✅ Full | tuple_insert + multi_insert override |
| UPDATE | ✅ Full | tuple_update override + ctid preservation |
| DELETE | ✅ Full | No-op (heapam header-only delete, no column data touched) |
| HOT updates | ❌ Disabled by design | A changed indexed column is re-encrypted under a fresh IV so its on-disk bytes always differ (PSQLE-219), so heap_update never chooses HOT when an indexed column changed; the index stays coherent. See Limitation 7 and Running in Production |
| VACUUM | ✅ Full | Inherited from heapam (dead-tuple header only) |
| CTAS | ✅ Full | Per-table DEK registration before SELECT is executed |
pg_dump (plain) |
⚠️ Dump is plaintext | pg_dump reads via scan_getnextslot → decrypted. Use pg_dump_tde to re-encrypt the output. |
pg_dump_tde / pg_restore_tde |
⚠️ Full, except pkcs11 |
Encrypted logical backup: dump wrapped with AES-256-GCM + DEK sealed in backup header. Standalone tools have no PKCS#11 session/PIN handling yet — see “Configure Key Access → PKCS#11 / HSM” above. |
| Streaming replication | ✅ Full | WAL ships encrypted bytes; standby decrypts at TAM layer |
| Page checksums | ✅ Full | Checksums over encrypted content (complementary to GCM) |
| Logical replication (non-TOAST) | ✅ Full (v1.2) | pg_vault_tde_pgoutput plugin decrypts tuples before streaming. On PG ≥ 17.11 / 18.x the publisher must allow the plugin — see below |
| TOAST (large values > ≈2 kB) | ✅ Full | Heap-level round-trips functional; per-chunk storage encryption |
| Logical replication (TOAST columns) | ✅ Full (v1.7) | Custom WAL rmgr (toast_custom_rmgr) routes encrypted chunks past the reorder buffer; stitched in change_cb. UPDATE/DELETE need REPLICA IDENTITY FULL + PK. Same publisher requirement as above |
| Range scans / ordering on TDE indexes | ⚠️ Equality only (by design) | tde_btree serves =, IN, = ANY; ranges, ORDER BY, min/max and merge joins run as sequential scans. numeric and nondeterministic-collation columns cannot be indexed with it — see Limitation 2 |
CREATE INDEX USING gin/gist/hash/brin/btree on encrypted_heap |
⚠️ ERROR by default |
Not encrypted AMs; rejected unless pg_vault_tde.allow_plaintext_index = on (then allowed with WARNING) |
EXCLUDE constraint, native index cloned onto an encrypted partition, or native index kept through SET ACCESS METHOD encrypted_heap |
⛔ Not supported (→ v1.8) | Build plaintext keys past the guard above, with no check and no warning — do not use on encrypted tables; see Limitation 11 |
tde_btree ... INCLUDE (col) |
⚠️ ERROR (v1.7.2) |
The included payload is not encrypted; tde_btree rejects INCLUDE |
| Column-level encryption | 🔜 v1.8 | Per-column ENABLE COLUMN ENCRYPTION DDL |
Logical replication on PostgreSQL 17.11 / 18.x and newer
Those minors added the output_plugin_libraries GUC (default
pgoutput, test_decoding): PostgreSQL now refuses to load any library outside
that list as a logical decoding output plugin. Creating a slot with this
extension’s plugin therefore 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.
Add the plugin on the publisher and reload — no restart needed:
# postgresql.conf on the publisher
output_plugin_libraries = 'pgoutput, pg_vault_tde'
It has to be in the server configuration: the process that loads the plugin is
the walsender, so a session-level SET does not reach it. Older minors have no
such GUC, and an unrecognised parameter in postgresql.conf is fatal at
startup — add the line only where SELECT ... FROM pg_settings WHERE name =
'output_plugin_libraries' returns a row. Details in
doc/pg_vault_tde.md → Logical Decoding and Replication →
Server configuration.
Testing
Two entry points, for two different needs.
Building from source, or packaging? One command, no container, no KMS service, no cluster to configure:
make install # into the tree your pg_config points at
make check-standalone # creates a throwaway cluster, runs the test, tears it down
make installcheck on its own fails against a stock cluster: the extension
registers a Table Access Method from _PG_init and must be preloaded.
check-standalone supplies that (and nothing else) through
test/regress.conf, so it needs no existing server and
touches none.
Working on the extension? The containerised suites cover what the smoke test above does not — the KMS providers, TAP, isolation, checksums, benchmarks:
# Full local CI pipeline (build + all tests + bench):
make ci-all
# Test against a specific PG version:
PG_VERSION=17 make ci-all
# Individual test stages:
make ci-regress # 141 SQL regression tests (vault provider) — numbered 1-140 + 154-164, with gaps
make ci-errorpath # 13 error-path tests (141-153) — exercises the PG_CATCH handlers
make ci-matrix # regress + TAP on the other supported PG majors (17, 19 when published)
make ci-scan-build # Clang static analyzer over the sources (compile only, ~1 min)
make ci-semgrep # the project's own Semgrep rules (ci/semgrep/), each tested on its own file, then run on src/
make ci-ubsan # Extension built with -fsanitize=undefined
make ci-asan # Extension built with -fsanitize=address, runtime preloaded into the stock server
make ci-valgrind # Valgrind memcheck over the full TDE workload (slow: 10-50x)
make ci-cassert # SQL suites + TAP files on PostgreSQL built --enable-cassert -DUSE_VALGRIND (builds PG from source)
make ci-wallet # SQL regression tests (local wallet provider)
make ci-checksums # regression tests + page checksum compatibility
make ci-tap # 49 TAP test files (starts a real Vault container for the Vault-dependent ones)
make ci-pins # every GitHub Action pinned by commit, every third-party image by digest, every downloaded binary checked (first stage of ci-all)
make ci-sbom # SPDX SBOM of the source bundle (syft), scanned by grype — informational, as on the release
make ci-security-report # the stages a security review cites, on this commit → doc/security/evidence/v<VERSION>.md (doc/SECURITY-REVIEW.md › Workflow)
make ci-soak # tap/43_soak.t alone: 30 min of random writes, rotations and immediate stops against a heap twin (SOAK_MINUTES, SOAK_SEED)
make ci-isolation # 3 isolation specs: DEK rotation under load, relation rewrite under a concurrent reader, UPDATE of out-of-line values racing another transaction
make ci-vault # Vault integration (Compose-based)
make ci-openbao # OpenBao Raft 3-node HA integration (12 tests)
make ci-upgrade # Read data written by the previous release tag (upgrade compatibility)
make ci-bench # Performance benchmark (encrypted vs plain heap)
make ci-bench BENCH_ROWS=100000 # with custom row count
# Cleanup:
make ci-clean # Remove test containers and images
Test coverage — 154 SQL regression tests (44 v1.4 + 20 v1.5 + 36 v1.6 + 41 v1.7 + 13 error-path), plus 1173 assertions across 49 TAP files (the soak test, tap/43, runs only under make ci-soak). Numbers are one sequence shared by every file and have gaps: 5-11 and 49 no longer exist, 80 was removed in v1.7, and 110 is disabled (the WITH HOLD cursor spill is a permanent limitation):
- Tests 1-4: extension loaded, access methods and SQL functions registered, wallet unlock
- Tests 12-14: TAM INSERT/SELECT/UPDATE end-to-end
- Test 15: DELETE
- Test 16: All-NULL rows (zero-length user data)
- Test 17: Index scan (index_fetch_tuple path)
- Test 18: COPY/bulk insert (multi_insert path)
- Test 19: Multi-column table (int, text, bool, numeric, timestamptz)
- Test 20: Per-table DEK isolation (two tables independently readable)
- Test 21: ANALYZE produces correct statistics on decrypted data
- Test 22: SELECT FOR UPDATE (tuple_lock path)
- Test 23: BitmapHeapScan (scan_bitmap_next_tuple path)
- Test 24: TABLESAMPLE (scan_sample_next_tuple path)
- Tests 25-48: UPSERT, MERGE, TRUNCATE, REINDEX, ALTER, JOINs, CTEs, HW accel, Vault, logical decoding
- Test 50: tde_btree CREATE INDEX + equality index scan (v1.4)
- Test 51: health_check() kms_provider column coherence with GUC (v1.6 realignment)
- Test 52: tde_btree UNIQUE constraint (v1.4)
- Tests 53-56: Per-table DEK catalog, wallet SQL stubs, rotation progress schema (v1.5)
- Tests 57-61: TOAST large-value round-trips (4 kB text, 8 kB jsonb, UPDATE, bulk COPY, raw-page check) (v1.5)
- Tests 62-64: Per-table DEK isolation (two tables; DEK-A cannot decrypt table-B), DROP TABLE catalog cleanup (v1.5)
- Tests 65-67: tde_btree native type operator classes (text, int4, uuid) (v1.5)
- Tests 68-69: Wire format v3 AEAD AAD — cross-table paste attack rejected (v1.5)
- Tests 70-72: Online key rotation BGW — concurrent SELECTs, progress tracking, BGW completion (v1.5)
- Tests 73-77: Wallet provider — init/unlock/lock, wallet_status() 5-col schema (wallet_exists, wallet_open, kek_algorithm, last_opened, file_perms), DEK round-trip with wallet KEK (v1.6)
- Tests 78-79: Wallet change_passphrase re-wraps under new KEK; rotate_kek re-wraps all per-table DEKs (catalog ciphertext changes; both tables remain readable) (v1.6 patch)
- Test 81: DDL hook registers BOTH parent and reltoastrelid in pg_vault_tde_catalog; DROP deregisters both (v1.6)
- Test 82: 64 KB compressible payload (pglz keeps it inline) — heap-level pre-TOAST + encrypt round-trip (v1.6)
- Test 83: Transactional rollback after pre-TOAST + encrypt keeps the table consistent and restores reltoastrelid (v1.6)
- Test 84: pg_vault_tde_verify_plaintext_on_disk() on STORAGE EXTERNAL payload — forensic helper confirms ciphertext on disk (v1.6)
- Test 85: STORAGE EXTERNAL round-trip — incompressible 80 KB payload produces real TOAST chunks (~41 in pg_toast_NNN); validates the TAM RELKIND_TOASTVALUE read-path bypass that allows plaintext chunks to round-trip through the encrypted parent table (v1.6 patch)
- Test 86: pg_vault_tde_verify_plaintext_on_disk() on STORAGE EXTERNAL incompressible payload (real TOAST chunk path; test-only helper, requires pg_vault_tde.dev_mode=on) (v1.6 patch)
- Test 87: pg_vault_tde_verify_toast_by_comparison() byte-for-byte TOAST forensic helper on STORAGE EXTERNAL incompressible payload (test-only helper, requires pg_vault_tde.dev_mode=on) (v1.6 patch)
- Test 88: STORAGE EXTERNAL — no compression, real TOAST chunks, plaintext visible via SELECT but absent on disk (v1.6)
- Test 89: STORAGE EXTENDED — compression + TOAST chunks + transparent DML (v1.6)
- Test 90: Storage metadata sanity — attstorage flags and TOAST presence (v1.6)
- Test 91: STORAGE EXTERNAL DELETE removes visible TOAST entries (v1.6)
- Test 92: VACUUM FULL on plain encrypted_heap table (v1.6)
- Test 93: VACUUM FULL on encrypted_heap table with TOAST data (v1.6)
- Test 94: CLUSTER on encrypted_heap with TOAST data (v1.6)
- Test 95: TOAST data readable via index scan (index_fetch_tuple) (v1.6)
- Test 96: TOAST data readable via BitmapHeapScan (v1.6)
- Test 97: TOAST data readable via SELECT FOR UPDATE (tuple_lock) (v1.6)
- Test 98: TOAST data readable via TABLESAMPLE (scan_sample_next_tuple) (v1.6)
- Test 99: TOAST data — ANALYZE computes statistics correctly (v1.6)
- Test 100: multi_insert (COPY path) with TOAST-triggering values (v1.6)
- Test 101: Multi-column TOAST — two large varlena attributes (v1.6)
- Test 102: UPDATE large→large exercises old_has_external branch in tuple_update (v1.6)
- Test 103: pg_vault_tde.toast_encryption=on — TOAST table uses encrypted_heap AM (pg_vault_tde_toast_am callback) (v1.6)
- Test 104: TOAST header overflow edge case (v1.6)
- Test 105: ALTER TABLE x SET ACCESS METHOD heap — convert encrypted to plain heap (v1.6)
- Test 106: ALTER TABLE x SET ACCESS METHOD encrypted_heap — convert plain heap to encrypted (v1.6)
- Test 107: Tuple readable after pg_vault_tde_rotation_online() completes (v1.6)
- Test 108: CREATE TABLE AS with encrypted_heap (v1.6)
- Test 109: VACUUM FULL on table with STORAGE EXTERNAL columns (v1.6)
- Tests 111-137: tde_btree native-type operator classes (int4/int8/uuid/date/timestamptz), DEK rotation + REINDEX, partitioned tables (routing, per-leaf DEK isolation, ATTACH/DETACH), FK relationships, CREATE/REINDEX INDEX CONCURRENTLY (v1.7 — sql/regression_test_v17.sql)
- Test 138: ALTER TABLE x SET ACCESS METHOD encrypted_heap on a populated table with genuinely out-of-line TOAST data (~13 KB, high-entropy so PGLZ can’t compress it back inline) — verifies an exact byte-for-byte round-trip via SELECT (Tests 105/106 only check on-disk bytes, never read the row back) plus post-ALTER UPDATE/DELETE across all four small/large transitions (v1.7, PSQLE-135 regression coverage)
- Test 139: ALTER TABLE x SET ACCESS METHOD heap — reverse direction of Test 138, same coverage (v1.7, PSQLE-135 regression coverage)
- Test 140: CREATE TABLE AS SELECT from an encrypted_heap table with genuinely out-of-line TOAST data must re-externalize into the destination’s own TOAST table; verifies the destination survives the (unrelated, from its own point of view) source table being dropped (v1.7, PSQLE-135 regression coverage)
- Test 154: UPDATE on a table whose index sits on an attribute behind a variable-length column — the statement that segfaulted. heap_update() reads the indexed attributes straight off the page; under the v4 layout that walk took a varlena length header out of ciphertext and left the page. Also asserts no HOT update was chosen and that index and sequential scan agree after the indexed column changes (v1.7.2, PSQLE-165 regression coverage)
- Test 155: a row whose columns are all NULL round-trips. Its encrypted region is the AEAD framing and nothing else — a well-formed encoding of a zero-length plaintext that used to be rejected, making the row unreadable for good (v1.7.2)
- Test 156: the v5 layout keeps attribute values off disk while leaving the tuple structure readable; a plain-heap control proves the search would have found the needle if it were there (v1.7.2)
- Test 157: every on-disk tuple is physically walkable with the relation’s tuple descriptor — the invariant PSQLE-165 broke, asserted directly via pageinspect instead of through its symptom, plus a per-attribute plaintext check. Skips when pageinspect is unavailable (v1.7.2)
- Test 158: the indexed column’s position must not affect correctness — 8 combinations (attnum 1 / behind a varlena / behind a NULL varlena / behind a dropped column, × tde_btree and plaintext btree). PSQLE-165 hid for four releases because 38 of 38 regression tables put the key on the first column, the one position whose offset is cached and never walked (v1.7.2)
- Test 159: pg_get_wal_resource_managers() reports id 161 as pg_vault_tde — the id reserved on the PostgreSQL Custom WAL Resource Managers wiki. Fails on any change to TDE_RMGR_ID, which would make the previous release’s WAL unreplayable; tap/19 pins the same id in the WAL records and the startup log (v1.7.2, PSQLE-172)
- Test 160: ordering, min()/max(), ranges, LIKE prefixes and merge joins never read a tde_btree index and return what a sequential scan returns (v1.7.2, PSQLE-173)
- Test 161: =, IN (…) and = ANY (…) use the index, and every one of 2,000 values of five types is found through it (v1.7.2, PSQLE-173)
- Test 162: a range forced onto tde_btree is refused, never answered with wrong rows; ordering cannot be forced onto it at all (v1.7.2, PSQLE-173)
- Test 163: no creation path — CREATE INDEX, EXCLUDE in CREATE TABLE or ALTER TABLE, the rebuild behind ALTER COLUMN … TYPE — builds a tde_btree index on numeric, a nondeterministic collation or a plaintext-key operator class; REINDEX, CONCURRENTLY included, keeps working (before the fix, UNIQUE and EXCLUDE on such an index let 1164 and 1332 exact duplicates of 2,000 in) (v1.7.2, PSQLE-173)
- Test 164: shapes that could still lead the planner onto tde_btree — ranges it derives from LIKE, ^@ and regex prefixes under collation C, > ANY, row comparisons, window functions, DISTINCT … ORDER BY, skip scan, range joins, IS NULL, ORDER BY/max() across partitions — all match a sequential scan (v1.7.2, PSQLE-173)
Error-path coverage — tests 141-153 (sql/regression_test_errorpath.sql, make ci-errorpath):
Every other test file exercises the success path. These exercise the PG_CATCH handlers in the TAM write paths — code that only ever runs after a longjmp, and that cleanses plaintext key material and frees intermediates. No success-path test can reach it. The suite locks the wallet so tde_gcm_encrypt() raises from inside tde_encrypt_heap_tuple(), i.e. from inside the PG_TRY of every write path, then forces 145 aborted writes through those handlers.
- Test 141:
pg_vault_tde_tuple_inserthandler ×25 — also the guard that the DEK really is unreachable (a vacuous run is a failure, not a pass) - Test 142:
pg_vault_tde_multi_inserthandler ×10 batched COPY of 100 small rows — the aliasing case wheretoasted_inflight == plain_inflight - Test 143:
pg_vault_tde_multi_insert+pg_vault_tde_toast_save_datumhandlers ×5 via the custom TOAST chunk writer - Test 144:
pg_vault_tde_tuple_updatehandler ×25 - Test 145:
pg_vault_tde_tuple_insert_speculativehandler ×25 (ON CONFLICT) - Tests 146-147:
pg_vault_tde_relation_copy_for_clusterhandler ×20 (VACUUM FULL, CLUSTER) - Test 148: fixture is byte-identical after the error storm — this is what proves 142/143/146/147 aborted rather than committed
- Test 149: TOAST payloads still decrypt byte-for-byte
- Tests 150-151: no plaintext in the main fork or the TOAST relation afterwards (
STORAGE EXTERNALis required, and asserted, so the TOAST scan cannot pass vacuously on a 0-byte file) - Test 152: every write path, plus VACUUM FULL and CLUSTER, healthy again after 145 longjmps
- Test 153: core must not re-TOAST the ciphertext — sweeps 29 payload sizes across
TOAST_TUPLE_THRESHOLDplus UPDATE, UPSERT and COPY at the boundary. Guards a segfault:heap_toast_insert_or_update()fires on tuple size as well as on external attributes, so clearingHEAP_HASEXTERNALalone leaves a window as wide as the AES-GCM overhead in which core deforms ciphertext as varlena andtoast_save_datum()crashes
Deep-checking stages — five tools, five different bug classes. run-all.sh --skip-deep skips all of them; they are the only stages that cost more than a couple of minutes.
| stage | sees | cost |
|---|---|---|
ci-scan-build |
per-path symbolic execution: NULL deref on one branch, sizes from a length that can be zero | ~1 min, compile only |
ci-ubsan |
undefined behaviour: signed overflow, oversized shifts, misaligned loads, nonnull violations |
minutes, no PG rebuild |
ci-asan |
memory errors outside palloc: overflows of malloc’d, stack and global buffers (OpenSSL, libcurl, libc), use after free | minutes, no PG rebuild |
ci-valgrind |
memory ownership: invalid/double free() of malloc’d state, out-of-bounds, uninitialised reads |
10-50x runtime |
ci-cassert |
Assert() calls that run nowhere else, plus MEMORY_CONTEXT_CHECKING — the only stage that catches a double pfree() of a palloc chunk |
builds PostgreSQL from source |
ci-cassert is the one worth the wall-clock. --enable-cassert executes the Assert() calls this codebase is full of — none of which run in any packaged build — and turns on MEMORY_CONTEXT_CHECKING, which poisons freed chunks and validates the header on every pfree(). A double free in a PG_CATCH handler becomes a loud failure instead of a silent no-op that the aborting transaction covers up moments later. Neither flag exists in a PGDG or Debian package, which is why the image builds the server from source. The stage runs the four regression files, the error-path suite and the tap/ files (the Vault ones skip): the TAP scenarios reach paths no SQL file does — a failed rotation, a restart between two statements — and a failed rotation crashed the worker on this build until the TAP files ran here.
ci-ubsan and ci-asan use the TDE_SANITIZE Makefile knob (make TDE_SANITIZE=undefined, =address), which instruments only our objects — the server binary stays stock, so no PostgreSQL rebuild is needed. ASan’s runtime is preloaded into the server with LD_PRELOAD, and the stage checks from a backend that it and the module are mapped before trusting a clean report. palloc’d chunks carry no redzones: those stay ci-valgrind’s and ci-cassert’s.
ci-semgrep is not a deep stage: it takes seconds. Each rule in ci/semgrep/ encodes a mistake this code base made or must not make — superuser() in a function that may be SECURITY DEFINER, a write to rd_tableam, memcmp() on a MAC or tag, a secret freed without OPENSSL_cleanse() or passed to a message, a random source other than pg_strong_random(), a client-tool query calling the extension unqualified — and comes with a test file saying where it must and must not fire. Any finding fails the stage; a line that is right in context carries a nosemgrep: <rule> comment saying why.
Concurrency — make ci-isolation (test/isolation/specs/):
per_table_dek_rotation.spec— online DEK rotation racing readers, writers and VACUUMencrypted_rewrite_concurrency.spec—VACUUM FULL/CLUSTER(i.e.pg_vault_tde_relation_copy_for_cluster) with a second backend holding aREPEATABLE READsnapshot across the relfilenode change, and with an uncommitted writer the rewrite must wait for. Payloads areSTORAGE EXTERNALso the rewrite has real TOAST chunks to migrate. This spec found thetoast_save_datum()segfault that TEST 153 now guards; it does not coverwallet_lock(), because the stage setswallet_dev_mode_passphraseand those permutations would pass vacuously — the spec says so in a comment rather than shipping a test that checks nothingtoast_update_concurrency.spec— anUPDATEthat replaces an out-of-line value while another transaction updates or deletes the same row: the waitingUPDATEthat skips the row, goes ahead on the newer version, or finds it deleted, and aDELETEwaiting on anUPDATE. Each permutation ends withVACUUMand counts the values left in the TOAST relation, so a lost value and an orphaned one both show (PSQLE-193). The expected output is what the same spec prints on a plainheaptable
On-disk corruption — tap/20_ondisk_fuzz.t:
Flips 72 random bits across the heap file over 6 rounds (fixed seed, so a failure reproduces) and classifies every row afterwards. The property under test is that the layer has exactly two behaviours under arbitrary damage — correct data, or a refusal — and never hands the client a value derived from damaged ciphertext. Data page checksums are disabled for this test on purpose: with them on, PostgreSQL rejects the page before the extension is asked to decrypt anything, and the test would measure core’s checksums instead of AES-256-GCM.
A third outcome is counted separately and accepted: the row vanishing. Our wire format keeps the HeapTupleHeader in plaintext and authenticates only the attribute values, so a flip in xmin, infomask or the null bitmap is outside the GCM tag by construction and can make the tuple invisible (what the tag does not cover: doc/pg_vault_tde.md). That is data loss from unauthenticated-header damage, not a forged value — the test distinguishes the two rather than conflating them.
Cross-version — make ci-matrix:
Every other stage runs on PG 18 only. This one runs the SQL regression suite and the TAP suite on the remaining supported majors, so a change that compiles everywhere but misbehaves on 17 cannot ship green. Not hypothetical here: pg_vault_tde_ambuild carries a PG17-specific impersonation, and the TAM notes a PG17 read-stream requirement in heapgettup.
TAP is in the matrix because it is the stage that needed it most. tap/20_ondisk_fuzz.t passed locally on PG 18 and broke CI on PG 17: initdb --no-data-checksums only exists from PG 18. TAP tests depend on the PostgreSQL::Test framework and on initdb/pg_ctl option spellings, all of which move between majors far more than SQL does.
A major whose base image is not published yet is skipped rather than failed, so PG 19 starts being covered on its own the day postgres:19 ships.
Memory safety — make ci-valgrind (sql/valgrind_workload.sql, ci/scripts/run-valgrind.sh):
Runs the postmaster under Valgrind memcheck with .valgrind.supp, over a workload covering every crypto-touching path on both the success and error side, then filters findings to stacks naming pg_vault_tde. Costs 10-50x, so --skip-valgrind is available in run-all.sh.
Note what it can and cannot see: the stock server package is not built with -DUSE_VALGRIND or --enable-cassert, so memcheck cannot see inside palloc — a use-after-pfree looks like a valid access into a malloc’d arena. It does catch invalid/double free() of malloc’d state (libcurl handles on the vault_transit_request error path), out-of-bounds access, uninitialised reads, and definite leaks. That limitation is why ci-errorpath exists alongside it rather than being replaced by it.
Test runner notes: -
make ci-regress(vault provider): 145/145 PASS, with conditional skips forwal_level(test 48) and wallet-only assertions (tests 74–80 whenkms_provider=localis required).pageinspectis installed byci/scripts/run-regress.sh, so the storage-level tests (61, 157) run rather than skip — until that line existed they asserted nothing in CI. -make ci-errorpath(local provider, nowallet_dev_mode_passphrase): 13/13 PASS. The absent GUC is load-bearing — with it set,wallet_lock()silently re-opens on the next DEK request and every statement succeeds, so the handlers are never entered. Test 141 detects that and fails rather than passing vacuously. -make ci-wallet(local provider): tests 73–79 PASS; test 80 SKIPS unlesswallet_passphrase_envis wired up; tests 81–109 also PASS in wallet mode. - Test 110 (WITH HOLD cursor plaintext spill) is permanently deferred — the executor’s tuplestore layer bypasses the TAM write path, so pg_vault_tde cannot intercept it without core modifications. The test is commented out inregression_test_v16.sql. - Tests 138–140 exist because Tests 105/106 didn’t catch two real bugs, both stemming from the same underlying cause:tde_decrypt_heap_tuple()copied the on-disk tuple header verbatim, including theHEAP_HASEXTERNALbit thattde_encrypt_heap_tuple()deliberately clears so core never dereferences a TOAST pointer inside ciphertext — leaving that bit WRONG on the decrypted tuple whenever the attribute genuinely is out-of-line. (1) The AAD was also bound to the wrong (transient) relation OID duringALTER TABLE’s row-by-row rewrite — fixed viaresolve_effective_relid()intde_compute_aad(). (2) Any consumer trusting the staleHEAP_HASEXTERNALbit instead of re-deriving it —pg_vault_tde_toast_insert_or_update()’s size-only gate, but also, more broadly,CREATE TABLE AS SELECT/INSERT ... SELECTreading out of anencrypted_heaptable — silently skips re-externalizing the value, leaving it pointing at storage that later disappears. Fixed at the source:tde_decrypt_heap_tuple()now recomputes the bit from the actual decrypted attributes (tde_tuple_has_external_desc()) before returning, so every consumer sees a truthful tuple. Both only reproduce with a populated source table and a genuinely out-of-line (not just inline-compressed) value.
Building
make && sudo make install
# Optional: -O3 -funroll-loops -fomit-frame-pointer instead of -O2
make TDE_OPTIMIZE=max && sudo make install
There is a single build. Hardware-accelerated AES (AES-NI, VAES, ARM Crypto
Extensions, SVE2) is provided automatically at runtime by OpenSSL’s own
default provider, based on the CPU the server is actually running on — this
requires no special compiler flags and no separate build. pg_vault_tde never
implements AES itself; it always calls into OpenSSL’s EVP API
(src/crypto/pg_vault_tde_hw_accel.c), which does its own CPUID/HWCAP
detection independent of how pg_vault_tde.so was compiled. Confirm what’s
actually active at runtime with:
make check-cpu # detect this machine's available CPU crypto extensions
make bench-cpu # OpenSSL AES throughput microbenchmark
SELECT * FROM pg_vault_tde_hw_accel_info();
Packages
The easiest way — no local build toolchain required (only podman or docker):
# Build all four packages (deb+rpm × pg17+pg18) into ./dist/
bash packaging/build_in_container.sh --all
# Single package (defaults: DEB, PG18, Ubuntu 22.04)
bash packaging/build_in_container.sh
bash packaging/build_in_container.sh --format rpm # RPM PG18
bash packaging/build_in_container.sh --pg-version 17 # DEB PG17
bash packaging/build_in_container.sh --format rpm --pg-version 17 # RPM PG17
OS version selection
Choose the base OS image for the build container:
# DEB — Ubuntu or Debian
bash packaging/build_in_container.sh --os-version ubuntu:22.04 # default (Jammy LTS)
bash packaging/build_in_container.sh --os-version ubuntu:24.04 # Noble LTS
bash packaging/build_in_container.sh --os-version debian:12 # Bookworm
bash packaging/build_in_container.sh --os-version debian:13 # Trixie
# RPM — Rocky Linux or AlmaLinux (EL-compatible)
bash packaging/build_in_container.sh --format rpm --os-version rockylinux:9 # default (EL9)
bash packaging/build_in_container.sh --format rpm --os-version rockylinux:10 # EL10
bash packaging/build_in_container.sh --format rpm --os-version almalinux:9 # EL9 (AlmaLinux)
bash packaging/build_in_container.sh --format rpm --os-version almalinux:10 # EL10 (AlmaLinux)
OpenSSL 3.x is required (the KMS layer uses EVP_EncryptInit_ex2/AES-256-WRAP
key wrapping, added in OpenSSL 3.0), so OSes that only ship OpenSSL 1.1.1 —
Debian 11 (Bullseye) and EL8 (Rocky/AlmaLinux 8) — are not supported.
Options compose freely:
# RPM for PG17 on Rocky Linux 9
bash packaging/build_in_container.sh \
--format rpm --pg-version 17 --os-version rockylinux:9
# DEB for PG18 on Debian 12
bash packaging/build_in_container.sh --os-version debian:12
If you have a local build environment, invoke the underlying scripts directly:
# Debian / Ubuntu
bash packaging/build_deb.sh --no-sign
# RHEL / Rocky / Fedora
bash packaging/build_rpm.sh
Verifying a release
From 1.7.2 on, every GitHub Release carries SHA256SUMS — the SHA-256 of every
asset — and SHA256SUMS.asc, its signature by a maintainer, whose key’s fingerprint
is in SECURITY.md. With the release’s files
downloaded:
gpg --import packaging/RELEASE-KEYS.asc # the maintainers' public keys
gpg --fingerprint # compare with SECURITY.md
gpg --verify SHA256SUMS.asc SHA256SUMS
sha256sum --check --ignore-missing SHA256SUMS
The signed review of the release (doc/security/reviews/, see
doc/SECURITY-REVIEW.md) is inside the source bundle, with
its own signature next to it.
Each release also carries pg_vault_tde-X.Y.Z.spdx.json, an SPDX SBOM of the source
bundle, and pg_vault_tde-X.Y.Z.grype.txt, a vulnerability scan of it. Both are for
information: the extension vendors no code, and links OpenSSL, libcurl and libpq
from the system.
Encrypted Backups
pg_dump_tde / pg_restore_tde
Plain pg_dump decrypts rows at read time (via the TAM), so the dump file is
plaintext. pg_dump_tde closes this gap by piping the dump through
AES-256-GCM before touching disk:
# Encrypted dump
pg_dump_tde -h localhost -U postgres -d mydb -o /backup/mydb.tde
# Restore encrypted dump
pg_restore_tde -h localhost -U postgres -d mydb -i /backup/mydb.tde
All other
pg_dumpoptions are fed directly to it.
With the vault provider both tools read the Vault credentials of the server with
SHOW, which since 1.7.2 returns them only to a superuser: connect as one (PSQLE-224).
pg_basebackup
It’s possible to use pg_basebackup to create a base backup of the cluster and use it for a standby creation.
Primary configuration
No special configuration is needed on the primary: encrypted relations are copied as-is by pg_basebackup, and the wrapped DEKs travel inside pg_vault_tde_catalog (part of the data directory). The KEK never travels with the backup — it stays in the KMS/wallet, exactly as with Oracle RMAN, SQL Server and Percona pg_tde.
Standby / restore configuration
The wrapped DEKs arrive with the base backup, but the KEK must be made available on the target separately:
localprovider — copy the primary’swallet.p12to the standby (it lives outsidePGDATA, so it is not in the base backup).vaultprovider — point the standby at the same Vault; nothing to copy.
Sealing the DEK catalog (key sealing)
pg_vault_tde_seal_keys() writes a signed, point-in-time snapshot of every wrapped DEK to accompany the backup pg_vault_tde_unseal_keys() verifies and re-imports it on the target. This makes the key state tamper-evident and guards against key-rotation drift between primary and standby.
-- On the primary, before pg_basebackup:
SELECT pg_vault_tde_seal_keys('/backup/keys.sealed', 'a-seal-passphrase');
pg_basebackup -h primary -D /backup/data -X stream
# local provider only: also transport the wallet, e.g.
# scp /path/to/wallet.p12 standby:/path/to/wallet.p12
-- On the standby, after restoring the data dir and providing the KEK:
SELECT pg_vault_tde_unseal_keys('/backup/keys.sealed', 'a-seal-passphrase');
The HMAC key is derived from the seal passphrase (PBKDF2-SHA256); it isindependent of the KMS provider, so the same bundle works for local and vault. unseal_keys verifies the HMAC before touching the catalog: a tampered bundle or wrong passphrase is rejected and nothing is written.
Key-rotation note: if the KEK/DEK is rotated after a backup, primary and standby can drift. Re-running seal_keys after a rotation (and unseal_keys on the standby) realigns the sealed key state with the data.
Concurrency note: don’t run
unseal_keys()whilepg_vault_tde_rotate_online()is rotating the same table. Postgres’s own MVCC checks make this fail safely — you’ll see atuple concurrently updatedor duplicate-key error and nothing will have been imported — just re-rununseal_keys()once the rotation finishes.
pg_basebackup_tde (automatic key sealing)
pg_basebackup_tde wraps pg_basebackup and performs the sealing step
automatically, for every database in the cluster that has the extension
(the DEK catalog is per-database, while pg_basebackup is cluster-wide):
# passphrase from a 0600 file (the ~/.pgpass pattern) ...
pg_basebackup_tde -h primary -D /backup/data -X stream \
--seal-passphrase-file /etc/pg_vault_tde/seal.pass
# ... or from the environment
export PG_VAULT_TDE_SEAL_PASSPHRASE='a-seal-passphrase'
pg_basebackup_tde -h primary -D /backup/data -X stream
The passphrase is never accepted as a command-line value: it would leak in
ps output and shell history. --seal-passphrase-file reads the first line
of the file and takes precedence over the environment variable.
All options are forwarded verbatim to pg_basebackup. For each database with
pg_vault_tde, the wrapper calls pg_vault_tde_seal_keys_bytea() before
the backup starts (point-in-time key snapshot) and, only if the backup
succeeds, writes one bundle per database next to it:
/backup/data/pg_vault_tde_keys.<datname>.sealed (mode 0600)
Use --keys-dir DIR to store the bundles elsewhere (e.g. outside PGDATA).
Databases without the extension are skipped; a failed backup leaves no bundle
files behind. Each session runs with an empty search_path and calls the function in
the extension’s own schema, whatever the database’s settings; up to 1.7.1 it used the
session’s search_path, and a database with the extension in a schema off that path
stopped the whole backup (“function pg_vault_tde_seal_keys_bytea(unknown, unknown) does
not exist”). The tar format (-Ft) is not supported — use the plain format
or run pg_vault_tde_seal_keys() manually.
Restore stays manual, exactly as above: restore the data dir, provision the
KEK, then per database
SELECT pg_vault_tde_unseal_keys('/backup/data/pg_vault_tde_keys.<db>.sealed', '...');
How it works
pg_dump_tdeforkspg_dump -Fcwith stdout redirected to a pipe.- It connects to PostgreSQL to read
pg_vault_tde.kms_providerfrom GUCs. - Generates a fresh DEK, wraps it via the active KMS provider, writes a
tde_backup_header(magic + format_version + wrapped_dek) to the output file. Reads the
pg_dumpstream in 64 KB blocks; encrypts each block as:[ Block length (4) | 0x02 (1) | IV (12) | Ciphertext | GCM-TAG (16) ]Block sequence number is bound as GCM AAD — reordering blocks is detectable.
(Reading from a stream with
freadnot guarantee that the block is 64 KB every time, that’s why the block length is stored)- If
pg_dumpfails mid-stream the partial output file is deleted automatically.
Block wire format
[ tde_backup_header ]
[ Block 0: Block length (4) | 0x02 | IV(12) | CT(64 KB) | TAG(16) ]
[ Block 1: Block length (4) | 0x02 | IV(12) | CT(64 KB) | TAG(16) ]
...
Each block is independently authenticated — corruption is detected at the block level, not only at EOF.
Current limitations
Only
-Fcformat is supported.-joption is NOT supported. Parallel jobs are only supported bypg_dumpif the directory format (-Fd) is set.Fixed block size: 64 KB.
Restore is locked to the original KEK used for DEK wrapping. This means that if we need to restore a dump into a new database that is using a different wallet (KMS local speaking) from the original, we can’t. The old wallet or a new wallet containing the old KEK is needed.
Currently (v1.7) deleting a database (
DROP DATABASE) deletes his .p12 wallet file. Dump files previous created from this database becomes undecryptable (if wallet file is lost).File-only output and input. The option
--outputor-o(forpg_dump_tde) and--inputor-i(forpg_restore_tde) are mandatory. Neither piping nor reading fromstdinare supported.
6. Executing pg_dump still produces a plain-text backup
Running pg_vault_tde in Production
A checklist for setting up and administering a cluster that keeps data in
encrypted_heap. Each item links to the section with the detail; the queries were
run against 1.7.2.
Before the first encrypted table
- Check the WAL resource manager id on every node that will load pg_vault_tde or replay its WAL — see 2. Configure PostgreSQL.
- Pick a real KMS. Vault/OpenBao, PKCS#11 or the local wallet — see
KMS Provider Selection.
pg_vault_tde.dev_modeandwallet_dev_mode_passphraseare for tests: the extension logs a WARNING on every use. - Size the DEK cache.
pg_vault_tde.max_encrypted_relations(default 1024, restart required) is one budget for the whole cluster. Every encrypted table, every partition and its partitioned parent, and everytde_btreeindex takes an entry. Past the budget nothing fails, but each access to an uncached relation goes back to the KMS — and the budget is also the cap on how many plaintext DEKs sit in shared memory. Count in every database, sum, add headroom:
SELECT count(*) FROM pg_vault_tde_catalog;
- Warm the cache after a restart with
pg_vault_tde.preload_keys = onif first-query latency matters — it needs a KMS that opens without an interactive unlock (see GUC Parameters). - Put temporary files on encrypted storage. Any query that spills past
work_mem— a sort, a hash, aWITH HOLDcursor, orCLUSTER’s sort pastmaintenance_work_mem— writes rows that are already decrypted to a temporary file, and no extension hook can intercept it (Limitation 6). Pointtemp_tablespacesat an encrypted filesystem, and setlog_temp_filesto see how much spills. - Treat the server log as sensitive. Statement text is logged with its literals: with
log_statement = 'mod'or'all', and by default for every statement that fails (log_min_error_statement = error). - Keep secrets out of statement text and out of
PGDATA.- A passphrase given to
pg_vault_tde_wallet_init(),_wallet_unlock(),_wallet_change_passphrase(),_migrate_vault_to_wallet()or a seal function is part of the statement: logged as above, and visible inpg_stat_activityto the same role and topg_read_all_statswhile the call runs. Make those calls from a session that has runSET log_statement = 'none'andSET log_min_error_statement = 'panic'. vault_token,vault_role_id,vault_secret_idandwallet_dev_mode_passphrasehold the secret itself, and exist only as settings. Never set them withSET(statement text),ALTER SYSTEM(it writespostgresql.auto.conf, insidePGDATAand so inside every base backup) orALTER DATABASE/ALTER ROLE … SET(stored inpg_db_role_setting, and in everypg_dumpall). Put them in a file outsidePGDATA, readable only by the server’s operating-system user, loaded withinclude. For the wallet, preferwallet_passphrase_env,_fileor_command, which hold only where the passphrase is. Up to 1.7.1 every member ofpg_read_all_settings— which PostgreSQL grants topg_monitor— read these four withSHOW,current_setting()andpg_settings; since 1.7.2 a role that is not a superuser reads********, andpg_settingslists none of them (PSQLE-224).wallet_passphrase_commandruns throughpopen()— a shell, as the server’s operating-system user — every time the wallet is opened; whatever it writes to standard error reaches the server log.
- A passphrase given to
Designing encrypted tables
- Keep sensitive values out of
PRIMARY KEYandUNIQUEconstraints. PostgreSQL backs them with a native btree, so the key column is stored in plaintext in that index; pg_vault_tde warns when you create one. Use a surrogate primary key (bigserial,uuid) and enforce uniqueness of a sensitive column withCREATE UNIQUE INDEX … USING tde_btree. A violation of that index reports the key inDETAILas ciphertext, not as the value. - Use
tde_btreefor equality lookups:=,IN (…),= ANY (…). AES-SIV preserves equality and nothing else, so the planner uses atde_btreeindex for those and nothing more (Limitation 2). Range predicates,ORDER BY,min()/max()and merge joins run as sequential scans: correct, not index-assisted — plan for that on columns you sort or range-filter. Two kinds of column cannot carry atde_btreeindex at all — noCREATE INDEX,EXCLUDEconstraint orALTER COLUMN … TYPEwill build one:numeric(correct support is planned for 1.8) andtextwith a nondeterministic collation. - Every index costs on every
UPDATE. HOT updates are off (Limitation 7), so eachUPDATEadds an entry to every index on the table, whether or not its columns changed. Keep indexes to what queries need, and don’t lowerfillfactorto make room for HOT: there is none to make room for. - Encrypt every partition. Encryption is per leaf. A
heappartition under anencrypted_heapparent stores its rows in plaintext without any error (regression test 127 asserts it). Find them:
SELECT p.relid::regclass AS unencrypted_leaf, parent.oid::regclass AS encrypted_parent
FROM pg_class parent
JOIN pg_am pa ON pa.oid = parent.relam AND pa.amname = 'encrypted_heap'
CROSS JOIN LATERAL pg_partition_tree(parent.oid) p
JOIN pg_class c ON c.oid = p.relid
LEFT JOIN pg_am a ON a.oid = c.relam
WHERE parent.relkind = 'p' AND p.isleaf
AND a.amname IS DISTINCT FROM 'encrypted_heap';
- Keep sensitive columns out of
pg_statistic.ANALYZEcomputes statistics on the decrypted values, so most-common values and histogram bounds land inpg_statisticin plaintext, on disk.ALTER TABLE t ALTER COLUMN c SET STATISTICS 0stops that — but only for future runs: a rowANALYZEalready wrote stays. Set it right afterCREATE TABLE, before data and autovacuum’s first analyze arrive. The planner then has no statistics for that column. Seedoc/pg_vault_tde.md→ Security Considerations. - Logical replication needs
REPLICA IDENTITY FULLplus a primary key forUPDATE/DELETE, andpg_vault_tde.toast_custom_rmgr = onfor TOAST columns (Limitation 3). The stream leaves the publisher decrypted: use TLS on the subscription connection andencrypted_heapon the subscriber.
Routine administration
- Vacuum more, table by table. Every
UPDATEleaves a dead heap tuple and a dead entry in every index, and autovacuum’s defaults assume HOT absorbs much of that. Lower the thresholds on update-heavy encrypted tables; start from something like:
ALTER TABLE orders SET (autovacuum_vacuum_scale_factor = 0.02,
autovacuum_analyze_scale_factor = 0.02);
- Measure bloat without
pgstattuple.pgstattuple()andpgstattuple_approx()rejectencrypted_heap(“only heap AM is supported”), andpgstatindex()rejectstde_btree(“is not a btree index”). Watch the statistics views and the size trend instead;n_tup_hot_updis always 0 here, by design:
SELECT s.relid::regclass AS table_name, s.n_live_tup, s.n_dead_tup,
s.n_tup_upd, s.n_tup_hot_upd, s.last_autovacuum, s.autovacuum_count
FROM pg_stat_user_tables s
JOIN pg_class c ON c.oid = s.relid
JOIN pg_am a ON a.oid = c.relam
WHERE a.amname = 'encrypted_heap'
ORDER BY s.n_dead_tup DESC;
SELECT i.indexrelid::regclass AS index_name, i.indrelid::regclass AS table_name,
pg_size_pretty(pg_relation_size(i.indexrelid)) AS index_size
FROM pg_index i
JOIN pg_class ic ON ic.oid = i.indexrelid
JOIN pg_am a ON a.oid = ic.relam
WHERE a.amname = 'tde_btree'
ORDER BY pg_relation_size(i.indexrelid) DESC;
- Rebuild bloated indexes online with
REINDEX INDEX CONCURRENTLY. It works ontde_btreebut never in parallel (Limitation 8), so it takes longer than on a plain btree. btree’s bottom-up deletion already removes most dead versions for updates that don’t touch the key; what vacuum cannot give back is pages that have split.tde_btreeindexes also never deduplicate, so on low-cardinality keys they are larger than a plain btree even right after a rebuild. - Rotate keys on a schedule — see Key Rotation. Online rotations are
tracked in the
pg_vault_tde_rotation_statusview, readable bypg_monitor. - Rotate the DEK of write-heavy tables before it reaches 232 encryptions. With
random 96-bit IVs, AES-GCM allows at most 232 (about 4.3 billion) encryptions under
one key (NIST SP 800-38D). Here the key is one DEK generation of one table, and every
tuple written counts: each
INSERT, eachUPDATE, each row thatVACUUM FULL,CLUSTERor a rotation rewrites, and each out-of-line chunk.rotate_online()starts a new generation. At 1,000 writes a second the limit is 50 days away; at 10,000, five. The statistics give an estimate — inserts and updates of the table and its TOAST relation, rotations included; they missVACUUM FULLandCLUSTERand restart from zero after a crash, so note them at each rotation and keep a wide margin:
SELECT s.relid::regclass AS table_name,
s.n_tup_ins + s.n_tup_upd + coalesce(t.n_tup_ins, 0) AS writes
FROM pg_stat_all_tables s
JOIN pg_class c ON c.oid = s.relid
JOIN pg_am a ON a.oid = c.relam AND a.amname = 'encrypted_heap'
LEFT JOIN pg_stat_all_tables t ON t.relid = c.reltoastrelid
ORDER BY writes DESC;
- Check integrity off-peak.
pg_vault_tde_verify_integrity('t')verifies the GCM tag of every tuple and fetches every out-of-line value it references — a full scan of the table and of its TOAST relation. It does not look at chunks no row references, nor at dropped columns. Up to 1.7.1 a failure could be false — autovacuum updating the table’s statistics during the scan was enough — so on 1.7.1 run it a second time, and check that the rows read, before treating a table as damaged. - Check health.
pg_vault_tde_health_check(),pg_vault_tde_hw_accel_info()(is AES-NI in use?), andpg_vault_tde_vault_status()orpg_vault_tde_wallet_status()for the KMS — see SQL Functions.
Backups, standbys and upgrades
- Don’t rely on plain
pg_dump: it writes decrypted rows. Usepg_dump_tdeandpg_basebackup_tde— see Encrypted Backups. - Back up the key material separately. A base backup carries the wrapped DEKs, never
the KEK. With the local provider the wallet lives outside
PGDATA, at/var/lib/pg_vault_tde/<db_oid>/wallet.p12, andDROP DATABASEdeletes it. Back up the wallet file and its passphrase on their own schedule: without them, every dump of that database is undecryptable. If the file goes missing, put it back:wallet_init()refuses to make a new one while any key of the database is wrapped under a local wallet, since a new KEK would open none of them (before 1.7.2 it made one, and the tables created afterwards were lost when the real file came back). CREATE DATABASE ... TEMPLATEdoes not copy encrypted data usably. Every row’s AAD names the database it was written in, so the clone’s copied rows never authenticate, and its catalog holds keys wrapped under the template’s wallet while its own wallet directory is empty. Drop the encrypted tables in the clone — orTRUNCATEthem, after copying the template’swallet.p12into the clone’s/var/lib/pg_vault_tde/<db_oid>/, to keep the empty tables. Copy encrypted data between databases withpg_dump_tdeandpg_restore_tde.- Standbys need pg_vault_tde preloaded, the id check above, and access to the KEK — a copy of the wallet, or the same Vault or HSM.
- Upgrades: follow the notes for each release. Upgrading to 1.7.2
needs one
VACUUM FULLper encrypted table and, withtoast_custom_rmgron, every node stopped cleanly and upgraded together.
Performance
Overhead vs Plain Heap
pg_vault_tde adds AES-256-GCM encryption/decryption and IV generation on every tuple read and write. The expected overhead depends on workload and row size:
| Workload | Typical Overhead | Notes |
|---|---|---|
| OLTP (mixed R/W, 100–500 B rows) | < 15% | Design target for this workload |
| Bulk INSERT (1M rows) | 25–40% | AES-GCM + pg_strong_random per tuple |
| Sequential scan (1M rows, read-only) | 20–35% | Decrypt + palloc copy per tuple |
| Index scan (point lookups) | < 5% | Single tuple decrypt per fetch |
Buffer Pin Behaviour
decode_slot copies the encrypted tuple from the shared buffer page and decrypts
it into a palloc’d plaintext tuple. The shared buffer pin is held until
ExecForceStoreHeapTuple() releases it internally — this preserves the
page-at-a-time access pattern of heapam’s sequential scan. Buffer hit counts
for encrypted tables should be comparable to plain heap (proportional to the
number of pages, not rows).
Limitations (v1.7)
See doc/ROADMAP.md for the full gap-closure roadmap.
tde_btree fixed-size type index key encryption — ✅ Resolved in v1.7:
int4,int8,uuid,date,timestamptzcolumns now have their btree index keys encrypted with AES-256-SIV, identical to varlena types. Index-only scans are not supported (by design, for security — seedoc/pg_vault_tde.md§ Index-Only Scans).TDE indexes answer equality only (by design):
tde_btreeencrypts keys with AES-256-SIV, which preserves equality but not order. The planner uses atde_btreeindex for=,IN (…)and= ANY (…)only; range predicates,ORDER BY,min()/max()and merge joins never read it and run as correct sequential scans, and a plan forced onto it for a range fails withtde_btree index "…" supports only equality lookupsrather than return rows. No path builds atde_btreeindex on anumericcolumn — equal values such as 1.5 and 1.50 do not encrypt alike, and the numeric operator class cannot order encrypted keys; correct support is planned for 1.8 — and columns with a nondeterministic collation, since AES-SIV only matches identical bytes. Up to 1.7.1 all of these were silent defects: wrong rows on ranges, ordering andmin/maxfortext,byteaandnumeric,cache lookup failed for type …onIN, missed rows onnumericequality — see Upgrading to 1.7.2.Logical replication of TOAST columns (✅ resolved in v1.7): Enable
pg_vault_tde.toast_custom_rmgr(PGC_POSTMASTER, default off) to publish externally-TOASTed columns to subscribers. UPDATE/DELETE requireREPLICA IDENTITY FULLand a primary key;REPLICA IDENTITY DEFAULTand PK-less tables remain unsupported (the replica identity would be read from ciphertext). See doc/pg_vault_tde.md → “Logical Decoding and Replication”.All-or-nothing table encryption (→ v1.8): All columns in an
encrypted_heaptable are encrypted. Per-columnENABLE COLUMN ENCRYPTIONDDL is planned for v1.8.WAL unencrypted (permanently deferred): Full WAL encryption requires a hook in
XLogInsert()/XLogWrite()— not achievable as a PostgreSQL extension.WITH HOLDcursor temporary file is unencrypted (permanently deferred): PostgreSQL materializes aCURSOR WITH HOLD’s entire result set into a tuplestore when the declaring transaction commits, so the cursor can still be fetched from afterward. Once that result set exceedswork_mem, the tuplestore spills to a temporary file on disk, and that file is written in plaintext. The tuplestore is populated directly by the executor, bypassing the table access method write path entirely, sopg_vault_tdenever gets a chance to encrypt the data before it reaches disk — there is no extension hook anywhere in theWITH HOLDcursor lifecycle (parse, plan, portal start, commit-time persist) that can intercept it. This is an inherent limit of the extension APIs, not of this implementation: temporary files produced by query execution that exceedwork_memare not covered by table-level encryption. The spilled file can outlive the query that created it — it persists for as long as the held cursor remains open, and, like any other PostgreSQL temp file, is not guaranteed to be cleaned up if the server crashes before the owning session ends normally.Mitigation: set
work_memlarge enough that cursor result sets are expected to stay in memory, and avoid declaringWITH HOLDcursors over queries that touchencrypted_heaptables (directly or through a view) in memory-constrained environments or wherever the result set size can’t be bounded in advance.HOT updates are disabled by design (so that updating an indexed column always maintains the index): On an
encrypted_heaptableheap_updatenever chooses a HOT (heap-only) update — every UPDATE writes new index entries, keepingtde_btreeindexes coherent without aREINDEX. How:heap_updatedecides whether an update is HOT by comparing the indexed columns between the old and the new tuple, on disk. Both images are 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, the row is encrypted again under another IV (PSQLE-219).heap_updatesees 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. See doc/pg_vault_tde.md § Known Limitations for the full analysis (including the v3 and v4 bugs this resolved). Operational cost: everyUPDATEwrites every index and leaves dead entries in all of them — vacuum, bloat monitoring andREINDEX CONCURRENTLYare covered in Running pg_vault_tde in Production.Parallel index build/rebuild is disabled by design: the parallel workers that PostgreSQL uses to build or rebuild an index run in separate processes that are not intercepted by the TAM/IAM wrappers, so a parallel worker would read raw ciphertext as if it were plaintext. This is disabled via
amcanbuildparallel = falseontde_btree.Only
tde_btreeis an encrypted index AM (→ v1.8 for GIN/Hash/GiST):CREATE INDEX/CREATE UNIQUE INDEX USING gin/gist/hash/brin/btreeagainst anencrypted_heaptable is rejected withERRORby default, because none of those access methods encrypt the key they store — onlytde_btree(AES-256-SIV) does. Setpg_vault_tde.allow_plaintext_index = onto allow it anyway (with aWARNING) when you need trigram/full-text/spatial search or a plain range-scan index on an encrypted table and have accepted that the indexed values will sit in plaintext on disk in that one index. This has caught out users trying to build aPRIMARY KEY/UNIQUEindex as two separate steps (CREATE UNIQUE INDEX ... USING btreethenALTER TABLE ... ADD CONSTRAINT ... USING INDEX, the pattern used withCREATE INDEX CONCURRENTLY): the first statement fails outright, so the table ends up with no index at all — not a broken one — and duplicate inserts go through unblocked simply because there is nothing left to enforce them.PRIMARY KEY/UNIQUEdeclared as a normal table constraint (inline inCREATE TABLE, orALTER TABLE ... ADD CONSTRAINT ... PRIMARY KEY (col)withoutUSING INDEX) is unaffected by this setting and always works — PostgreSQL core forces those onto a native btree index regardless, so pg_vault_tde can only warn about it, never block it.Plain
COPY ... TO/pg_dumpproduce a plaintext dump, with no warning (→ v1.8): encryption inpg_vault_tdelives entirely in the table access method’s read callbacks (scan_getnextslotand friends), which decrypt unconditionally and have no way to tell aSELECTapart from aCOPY <table> TO ...— both dispatch through the sametable_scan_getnextslot()call.pg_dump’s default table-data path is exactly this form ofCOPY ... TO stdout, so a plainpg_dump(or a manualCOPY sensitive_table TO '/path') on anencrypted_heaptable silently returns fully decrypted rows — there is currently noProcessUtility_hookguard or GUC-gatedWARNINGfor this (a “dump plaintext warning” was designed but never implemented). Mitigation: always usepg_dump_tde/pg_restore_tdeinstead of plainpg_dump/pg_restorefor logical backups of encrypted tables — see Encrypted Backups.Three index paths are not supported on
encrypted_heapin 1.7.2 (→ v1.8): they slip past theCREATE INDEXguard of Limitation 9 and build a native index whose keys sit in plaintext on disk, without theallow_plaintext_indexcheck and without aWARNING. Until 1.8 rejects them, do not use them on an encrypted table:- an
EXCLUDEconstraint backed by a native access method (e.g.EXCLUDE USING gist (c WITH &&)) — it is a constraint, so it does not go through the index guard; - a native index cloned onto an encrypted partition —
CREATE TABLE … PARTITION OF … USING encrypted_heap(orATTACH PARTITION) under a partitioned parent that already carries a native index; the clone is created internally and skips the check; - a native index carried over by
ALTER TABLE … SET ACCESS METHOD encrypted_heap— indexes that already existed on the table stay as they were.
Mitigation: index encrypted tables only with
tde_btree(equality), and add a partition’s indexes or convert a table toencrypted_heapbefore creating native indexes on it; if you must keep one, do so knowingly withpg_vault_tde.allow_plaintext_index = on.INCLUDEcolumns on atde_btreeindex had the same effect and are now rejected outright (v1.7.2).- an
Community & Contributing
| Report a bug | Open a bug report — check Known Limitations first |
| Request a feature | Open a feature request |
| Report a vulnerability | Privately — see SECURITY.md. Never in a public issue. |
| Contribute code | CONTRIBUTING.md explains the GitHub → Bitbucket mirror review flow |
| Community standards | CODE_OF_CONDUCT.md |
| Documentation | Project wiki |
| Commercial support | Miriade / Mircrypt — SLAs, custom development, security audits |
Contributions are welcome from anyone. This project is part of the PostgreSQL community and holds itself to that community’s standards of respectful, professional technical collaboration.
License
BSD License (PostgreSQL License) — see LICENSE.
Compatible with MIT, BSD, ISC, and Apache 2.0. Not derived from any GPL- or AGPL-licensed code.
Copyright
Copyright © 2026 Miriade S.r.l.