В pg_stat_ssl у соединения стоит ssl = t, трафик зашифрован, а клиент не знает, кто на другом конце: без корневого сертификата клиента устроит сертификат любого сервера. Дело в режиме sslmode: require только включает шифрование, имя сервера сверяет один verify-full. Отдельный вопрос — что шифруется кроме канала. Диск, столбец и TDE закрывают разные угрозы.
Пароль и метод входа настраиваются отдельно, в статье про pg_hba.conf и SCRAM; там же channel_binding=require, которому нужен TLS. Здесь настраивается и проверяется сам канал, а затем два слоя «на покое».
Три слоя шифрования: что закрывает каждый и чего не закрывает
verify-full защищает только канал и только если имя сервера сверяется; шифрование диска закрывает украденный носитель, но не работающий сервер; шифрование столбца лишает индекса и оставляет ключ на сервере.Стенд
Два контейнера в одной сети: сервер и клиент. У сервера второе сетевое имя dba-tls-alias: по нему клиент подключится к тому же серверу под именем, которого нет в сертификате. У обоих контейнеров --cpus 2, чтобы цифры pgbench ниже не зависели от остальной машины.
docker network create dba-tls-net
docker run -d --name dba-tls-server --network dba-tls-net --network-alias dba-tls-alias \
--memory 1g --cpus 2 -e POSTGRES_PASSWORD=pg postgres:18
docker run -d --name dba-tls-client --network dba-tls-net --memory 512m --cpus 2 \
--entrypoint sleep postgres:18 3600
Учебный CA и сертификат сервера создаются openssl прямо в контейнере сервера, в каталоге /var/lib/postgresql/tls. Имя в сертификате — dba-tls-server, оно же в subjectAltName:
openssl req -x509 -newkey rsa:2048 -nodes -keyout ca.key -out ca.crt -days 365 -subj "/CN=Training CA"
openssl req -newkey rsa:2048 -nodes -keyout server.key -out server.csr -subj "/CN=dba-tls-server"
printf 'subjectAltName=DNS:dba-tls-server\n' > san.ext
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out server.crt \
-days 30 -extfile san.ext -set_serial 1001
chmod 600 server.key
openssl x509 -in server.crt -noout -subject -issuer -serial -ext subjectAltName -dates
subject=CN=dba-tls-server
issuer=CN=Training CA
serial=03E9
X509v3 Subject Alternative Name:
DNS:dba-tls-server
notBefore=Oct 28 20:55:29 2026 GMT
notAfter=Nov 27 20:55:29 2026 GMT
Серийный номер 03E9 — это 1001 в шестнадцатеричной записи. Файл ca.crt кладётся клиенту как ~/.postgresql/root.crt: libpq берёт корневой сертификат оттуда, если не задан sslrootcert. Важная оговорка документации libpq: если такой файл есть, sslmode=require ради совместимости ведёт себя как verify-ca, то есть проверяет подпись CA, но не имя; полагаться на это документация не советует.
Включение TLS на сервере. Параметр ssl требует перезапуска, остальные перечитываются:
ALTER SYSTEM SET ssl = on;
ALTER SYSTEM SET ssl_cert_file = '/var/lib/postgresql/tls/server.crt';
ALTER SYSTEM SET ssl_key_file = '/var/lib/postgresql/tls/server.key';
ALTER SYSTEM SET ssl_min_protocol_version = 'TLSv1.3';
После docker restart dba-tls-server select name, setting, source from pg_settings where name like 'ssl%' показывает ssl = on и ssl_min_protocol_version = TLSv1.3 с источником configuration file. ssl_min_protocol_version отсекает старые версии протокола (документация); проверка клиентом, который умеет только TLS 1.2, — в последнем подключении ниже.
Режимы sslmode и что показывает опыт
Документация libpq даёт такую таблицу защиты (проверено 28.10.2026):
| sslmode | Защита от прослушивания | Защита от подмены сервера (MITM) |
|---|---|---|
disable | нет | нет |
allow, prefer | может быть | нет |
require | да | нет |
verify-ca | да | зависит от политики CA |
verify-full | да | да |
Значение по умолчанию — prefer, и документация прямо называет его бессмысленным с точки зрения безопасности: оно оставлено ради обратной совместимости. Каждое подключение спрашивает собственное состояние TLS из pg_stat_ssl (select ssl, version, cipher, bits from pg_stat_ssl where pid = pg_backend_pid()), пароль задан в PGPASSWORD.
Семь подключений из клиента, вывод дословно:
$ host=dba-tls-server user=postgres sslmode=disable
f|||
$ host=dba-tls-server user=postgres sslmode=require
t|TLSv1.3|TLS_AES_256_GCM_SHA384|256
$ host=dba-tls-alias user=postgres sslmode=require
t|TLSv1.3|TLS_AES_256_GCM_SHA384|256
$ host=dba-tls-alias user=postgres sslmode=verify-ca
t|TLSv1.3|TLS_AES_256_GCM_SHA384|256
$ host=dba-tls-server user=postgres sslmode=verify-full
t|TLSv1.3|TLS_AES_256_GCM_SHA384|256
$ host=dba-tls-alias user=postgres sslmode=verify-full
psql: error: connection to server at "dba-tls-alias" (192.168.64.2), port 5432 failed: server certificate for "dba-tls-server" does not match host name "dba-tls-alias"
$ host=dba-tls-server user=postgres sslmode=verify-full ssl_max_protocol_version=TLSv1.2
psql: error: connection to server at "dba-tls-server" (192.168.64.2), port 5432 failed: SSL error: tlsv1 alert protocol version
This may indicate that the server does not support any SSL protocol version between TLSv1.2 and TLSv1.2.
Что видно. Подключение verify-full по имени из сертификата (dba-tls-server) проходит, по имени, которого в сертификате нет (dba-tls-alias), отклоняется, хотя это тот же сервер по тому же адресу 192.168.64.2 и сертификат подписан доверенным CA. require и verify-ca по тому же имени проходят: verify-ca проверил подпись, но не имя. Клиент с потолком TLS 1.2 при ssl_min_protocol_version = TLSv1.3 отклоняется; в логе сервера в этот момент:
LOG: could not accept SSL connection: unsupported protocol
HINT: This may indicate that the client does not support any SSL protocol version between TLSv1.3 and TLSv1.3.
Сервера-двойника в опыте нет: проверяется сама сверка имени. Клиент не принимает сертификат, выданный на другое имя, даже когда CA ему доверен.
Где в коде проверка имени
Клиентская сторона. После рукопожатия pgtls_open_client вызывает проверку имени (fe-secure-openssl.c, L1523–L1527), а сама проверка начинается с того, что решает режим (fe-secure-common.c, pq_verify_peer_name_matches_certificate):
/*
* If told not to verify the peer name, don't do it. Return true
* indicating that the verification was successful.
*/
if (strcmp(conn->sslmode, "verify-full") != 0)
return true;
Любой режим, кроме verify-full, возвращает true до сверки: подпись цепочки при verify-ca проверяется раньше, при рукопожатии, имя — нет. Текст из опыта «does not match host name» собирает та же функция чуть ниже (строка 294).
Ротация сертификата без рестарта
Новый сертификат (другой ключ, серийный номер 2002 = 07D2) ложится на место старого, затем сервер просят перечитать конфигурацию. Серийный номер, который отдаёт сервер, смотрит openssl s_client из клиента:
echo | openssl s_client -starttls postgres -connect dba-tls-server:5432 -servername dba-tls-server \
| openssl x509 -noout -serial -enddate
Замена файлов (server.key и server.crt перезаписаны новыми под тем же именем), проверка до и после select pg_reload_conf():
$ serial до
serial=03E9
notAfter=Nov 27 20:55:29 2026 GMT
$ serial после mv, до reload
serial=03E9
notAfter=Nov 27 20:55:29 2026 GMT
$ select pg_reload_conf()
t
$ serial после reload
serial=07D2
notAfter=Nov 27 20:56:47 2026 GMT
До перечитывания сервер продолжает отдавать старый сертификат, хотя файл на диске уже новый; после pg_reload_conf() отдаёт 07D2. pg_postmaster_start_time() остался 2026-10-28 20:55:47.000368+00, то есть рестарта не было, а новое подключение с verify-full сразу прошло (t|TLSv1.3). В логе: received SIGHUP, reloading configuration files.
Механизм в коде. postmaster при SIGHUP заново инициализирует TLS и, если не получилось, оставляет прежний (postmaster.c, L2020–L2027):
/* Reload SSL configuration as well */
if (EnableSSL)
{
if (secure_initialize(false) == 0)
LoadedSSL = true;
else
ereport(LOG,
(errmsg("SSL configuration was not reloaded")));
Новый контекст сначала собирается целиком, и только при успехе заменяет старый (be-secure-openssl.c, L404–L410, be_tls_init):
/*
* Success! Replace any existing SSL_context.
*/
if (SSL_context)
SSL_CTX_free(SSL_context);
SSL_context = context;
По коду: если в новых файлах ошибка, работающий сервер продолжает со старым контекстом (в опыте не воспроизводилось), а единственный след — строка SSL configuration was not reloaded в логе. Проверка после ротации — серийный номер на стороне клиента, а не код возврата pg_reload_conf(). Ключ с парольной фразой отключает перечитывание без рестарта, если не задан ssl_passphrase_command_supports_reload (документация, проверено 28.10.2026).
Сколько стоит TLS
Те же два контейнера, по три прогона pgbench для каждого режима, прогоны чередуются: сначала без TLS, потом с sslmode=verify-full. База pgbench -i -s 10. Первая серия — только чтение (-S, 8 клиентов, 2 потока, 20 секунд). Вторая — тот же запрос, но каждое выполнение на новом соединении (-S -C, 4 клиента, 10 секунд): так видна цена рукопожатия.
Команда одна и та же, меняется PGSSLMODE (disable или verify-full): pgbench -S -c 8 -j 2 -T 20 -n postgres и pgbench -S -C -c 4 -j 2 -T 10 -n postgres.
Вывод tps по прогонам:
S disable run1 tps = 86664.866517 (without initial connection time)
S verify-full run1 tps = 66095.368643 (without initial connection time)
S disable run2 tps = 81925.745343 (without initial connection time)
S verify-full run2 tps = 65782.834047 (without initial connection time)
S disable run3 tps = 81875.246123 (without initial connection time)
S verify-full run3 tps = 63941.295172 (without initial connection time)
SC disable run1 tps = 426.361901 (including reconnection times)
SC verify-full run1 tps = 286.226060 (including reconnection times)
SC disable run2 tps = 393.946187 (including reconnection times)
SC verify-full run2 tps = 304.027738 (including reconnection times)
SC disable run3 tps = 436.113039 (including reconnection times)
SC verify-full run3 tps = 284.049596 (including reconnection times)
| Режим | Без TLS, медиана (разброс) | verify-full, медиана (разброс) | Разница по медиане |
|---|---|---|---|
-S, постоянные соединения | 81 926 (81 875–86 665) | 65 783 (63 941–66 095) | −19,7 % |
-S -C, соединение на запрос | 426,4 (393,9–436,1) | 286,2 (284,0–304,0) | −32,9 % |
Сервер и клиент работают на одной машине в одной сети docker, процессор делят оба, а запросы короткие, поэтому доля шифрования в этом тесте выше, чем при длинных запросах по настоящей сети. Цифры годятся для сравнения режимов на этом стенде, а не как цену TLS вообще. Во второй серии потеря больше (−32,9 % против −19,7 %): рукопожатие добавляется к каждому запросу. Если приложение открывает соединение на запрос, помогает пул соединений, а не отказ от TLS.
Шифрование «на покое»: диск и столбец
Канал закрыт, но файлы базы лежат на диске в открытом виде. В документации PostgreSQL два способа, оба из раздела «Варианты шифрования» (проверено 28.10.2026).
Шифрование диска (dm-crypt + LUKS на Linux) мешает прочитать данные с украденного диска или всего компьютера, но, как сказано в документации, не защищает, пока файловая система смонтирована: операционная система отдаёт открытый вид. Ключ тоже приходится как-то передавать системе при монтировании. Этот слой в опыте не проверялся: LUKS требует прав на хосте, которых у стенда нет.
Шифрование столбца (модуль pgcrypto) оставляет открытым всё остальное. Документация предупреждает: ключ приходит на сервер вместе с запросом, так что системный администратор сервера может его перехватить. А вот что стоит за словами «столбец теряет индекс» — проверено:
CREATE EXTENSION pgcrypto;
CREATE TABLE users (id int PRIMARY KEY, email text, email_enc bytea);
INSERT INTO users SELECT g, 'user' || g || '@example.com', pgp_sym_encrypt('user' || g || '@example.com', 'k3y')
FROM generate_series(1, 100000) g;
CREATE INDEX users_email_idx ON users (email);
CREATE INDEX users_email_enc_idx ON users (email_enc);
ANALYZE users;
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF) SELECT id FROM users WHERE email = 'user77777@example.com';
Index Scan using users_email_idx on users (actual rows=1.00 loops=1)
Index Cond: (email = 'user77777@example.com'::text)
Index Searches: 1
Buffers: shared hit=1 read=3
Planning Time: 0.107 ms
Execution Time: 0.030 ms
Тот же поиск по шифрованному столбцу двумя способами. Первый — сравнить шифртекст:
SELECT count(*) FROM users WHERE email_enc = pgp_sym_encrypt('user77777@example.com', 'k3y');
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF) SELECT id FROM users WHERE email_enc = pgp_sym_encrypt('user77777@example.com', 'k3y');
count
-------
0
Seq Scan on users (actual rows=0.00 loops=1)
Filter: (email_enc = pgp_sym_encrypt('user77777@example.com'::text, 'k3y'::text))
Rows Removed by Filter: 100000
Buffers: shared hit=1809
Execution Time: 21854.465 ms
Строка не нашлась, хотя она в таблице: при каждом шифровании pgp_sym_encrypt берёт случайную соль и сеансовый ключ, поэтому одно и то же значение даёт каждый раз разный шифртекст. Функция помечена VOLATILE (provolatile = v в pg_proc), планировщик вычисляет её заново для каждой строки и индекс по email_enc использовать не может. Второй способ — расшифровать столбец в WHERE:
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF) SELECT id FROM users WHERE pgp_sym_decrypt(email_enc, 'k3y') = 'user77777@example.com';
Seq Scan on users (actual rows=1.00 loops=1)
Filter: (pgp_sym_decrypt(email_enc, 'k3y'::text) = 'user77777@example.com'::text)
Rows Removed by Filter: 99999
Buffers: shared hit=1809
Execution Time: 21706.050 ms
Строка найдена, но ценой чтения всей таблицы и расшифровки 100 000 значений: 21,7 секунды против 0,030 миллисекунды у индекса по открытому столбцу. Индекс по email_enc при этом занимает 11 МБ против 3992 КБ у индекса по email и ни разу не пригодился. Само значение выросло с 17 байт до 83.
TDE: что есть и чего нет
В PostgreSQL сообщества встроенного прозрачного шифрования данных (TDE) нет: раздел документации «Варианты шифрования» его не упоминает, а вики-страница о TDE описывает предложение, которое обсуждается в pgsql-hackers (проверено 28.10.2026). Принятого решения на странице нет.
В других продуктах TDE есть, и по документации выглядит так (все страницы проверены 28.10.2026, опыта на них нет):
- Postgres Pro Enterprise: расширение
pgpro_tdeпреобразует файлы таблиц, индексы, WAL и временные файлы на уровне страниц; данные преобразуются на пути на диск (G.13). Документация называет цель: защитить данные от тех, у кого есть доступ к резервным копиям или права чтения с сервера. Можно преобразовывать только конфиденциальные данные, перенеся их в отдельное табличное пространство, поскольку преобразование заметно расходует процессор. - MySQL 8.4: ключи шифрования табличных пространств InnoDB хранятся в keyring; страница «Keyring» отсылает к разделу 17.13 «InnoDB Data-at-Rest Encryption». Сам раздел 17.13 при проверке вернул 403, поэтому синтаксис включения шифрования таблиц здесь не приводится.
- MongoDB: Encrypted Storage Engine — функция только Enterprise-выпуска и только для WiredTiger (документация).
Если по требованиям нужен TDE, сообщество PostgreSQL его не даёт: остаются шифрование диска, коммерческая сборка и шифрование на стороне приложения. Требования закона и стандартов (152-ФЗ, PCI DSS) к шифрованию в этой статье не разбираются: их толкование — за юристом.
Что из этого следует
sslmode=require в строке подключения создаёт ощущение защиты и не даёт её от подмены: без корневого сертификата у клиента он принимает сертификат от любого CA, а с ним проверяет только подпись, но не имя — как verify-ca, который в опыте пустил клиента по чужому имени. Защищает пара «verify-full у клиента + корневой сертификат, которому клиент доверяет» — и только если клиент подключается по имени, которое есть в сертификате.
Ротация сертификата — штатная операция: файлы на место, pg_reload_conf(), серийный номер на стороне клиента. Проверять нужен именно номер: при ошибке в файле сервер остаётся на старом сертификате.
Шифрование столбца применяют к отдельным значениям, по которым не ищут: номерам документов, токенам. Поле, по которому нужен поиск, придётся хранить ещё и в виде индексируемого ключа (хеша) либо вовсе не шифровать на уровне базы.
Чек-лист
- Клиентам задай
sslmode=verify-fullиsslrootcert;preferпо умолчанию иrequireот подмены не защищают. Подключайся по имени из сертификата. - На сервере задай
ssl_min_protocol_versionи проверь, что отказ приходит: старый клиент получаетunsupported protocolв логе. - После замены сертификата вызови
select pg_reload_conf()и сверь серийный номер:openssl s_client -starttls postgres -connect <хост>:5432 | openssl x509 -noout -serial; в логе не должно бытьSSL configuration was not reloaded. - Для приложений с соединением на каждый запрос поставь пул: в опыте
-Cстоил TLS −32,9 % против −19,7 % при постоянных соединениях. - Диск шифруй от кражи носителя, а не от доступа к работающему серверу;
pgcrypto— только для значений, по которым нет поиска.
Удалить стенд: docker rm -f -v dba-tls-server dba-tls-client && docker network rm dba-tls-net.