Справочник DBA: базы данных изнутри читается по порядку

Статья 11.3 · раздел 11 «Безопасность и соответствие» · 82 из 109

Шифрование PostgreSQL: sslmode=verify-full, ротация сертификата, диск, pgcrypto и TDE

опубликовано
читать
≈ 12 мин
автор
среда и версия
PostgreSQL 18.6 (Debian 18.6-1.pgdg13+2) в двух контейнерах postgres:18 (сервер с --memory 1g и клиент, у обоих --cpus 2) в одной сети docker, OpenSSL 3.5.7; опыт выполнен 28 октября 2026; pgbench scale 10, таблица pgcrypto на 100 000 строк
содержание решать задачи SQL на собеседовании

В pg_stat_ssl у соединения стоит ssl = t, трафик зашифрован, а клиент не знает, кто на другом конце: без корневого сертификата клиента устроит сертификат любого сервера. Дело в режиме sslmode: require только включает шифрование, имя сервера сверяет один verify-full. Отдельный вопрос — что шифруется кроме канала. Диск, столбец и TDE закрывают разные угрозы.

Пароль и метод входа настраиваются отдельно, в статье про pg_hba.conf и SCRAM; там же channel_binding=require, которому нужен TLS. Здесь настраивается и проверяется сам канал, а затем два слоя «на покое».

DBA / 01

Три слоя шифрования: что закрывает каждый и чего не закрывает

Слои шифрования: канал, диск, столбецКанал, TLS 1.3 с sslmode verify-full: закрывает запросы и ответы в сети и подмену имени сервера, не закрывает файлы на диске и бэкапы, а режим require имя не проверяет. Диск, LUKS по документации: закрывает кражу носителя или сервера, не закрывает смонтированный том и запросы к работающей СУБД. Столбец, pgcrypto: значение в файлах и дампе хранится как 83 байта вместо 17, закрывает чтение таблицы, не закрывает ключ, который приходит на сервер, а индекс по значению не работает, поиск идёт Seq Scan за 21,7 с на 100 000 строк.слойзакрываетне закрываетканал: TLSsslmode=verify-fullpg_stat_ssl: ssl = tзапросы и ответы в сетиподмену имени: verify-fullфайлы на диске, бэкапыrequire: имя не проверяетсядискLUKS, по документациикражу носителя или серверасмонтированный томи запросы к работающей СУБДстолбецpgcrypto83 байта вместо 17Seq Scan 21,7 сзначение в файлах и дампеключ уходит на серверпоиск по значению: индексне работаетизмерено на 100 000 строк и pgbench scale 10; LUKS и TDE только по документации
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(), серийный номер на стороне клиента. Проверять нужен именно номер: при ошибке в файле сервер остаётся на старом сертификате.

Шифрование столбца применяют к отдельным значениям, по которым не ищут: номерам документов, токенам. Поле, по которому нужен поиск, придётся хранить ещё и в виде индексируемого ключа (хеша) либо вовсе не шифровать на уровне базы.

Чек-лист

  1. Клиентам задай sslmode=verify-full и sslrootcert; prefer по умолчанию и require от подмены не защищают. Подключайся по имени из сертификата.
  2. На сервере задай ssl_min_protocol_version и проверь, что отказ приходит: старый клиент получает unsupported protocol в логе.
  3. После замены сертификата вызови select pg_reload_conf() и сверь серийный номер: openssl s_client -starttls postgres -connect <хост>:5432 | openssl x509 -noout -serial; в логе не должно быть SSL configuration was not reloaded.
  4. Для приложений с соединением на каждый запрос поставь пул: в опыте -C стоил TLS −32,9 % против −19,7 % при постоянных соединениях.
  5. Диск шифруй от кражи носителя, а не от доступа к работающему серверу; pgcrypto — только для значений, по которым нет поиска.

Удалить стенд: docker rm -f -v dba-tls-server dba-tls-client && docker network rm dba-tls-net.

Источники

Выжимки статей в Telegram: одна ловушка, код с выводом и ссылка на полный разбор. t.me/koddo_ru