Делаем Encryption at Rest для Mariadb с использованием AWS KMS
Что такое Encryption at rest и зачем это нужно?
Encryption at rest - часто переводится как “шифрование при хранении” или “шифрование в состоянии покоя”. Вы можете быть как угодно хорошо защищены от обычных атак типа SQL Injection или RCE, но что вы будете делать, если кто-то украдет жесткий диск, его образ или резервную копию?
Думая о таком, хочется воскликнуть - " Ну нет, воровать диски - это уже нечестно!" но у нас тут клуб для взрослых мальчиков, а значит - бои без правил.
Кроме того, encryption at rest - частое требование множества стандартов, таких как: PCI DSS, GDPR, HIPAA и 152-ФЗ.
Важно: Есть одна грубая ошибка которую нельзя допускать. Нельзя хранить зашифрованные данные рядом с ключом к ним. Если можно утащить одновременно и данные и ключ - шифрование теряет смысл. Поэтому простой подход с ключом на том же диске будет профанацией.
Как реализовать шифрование в покое самим простым сособом?
Делать так, мы, конечно, не будем. Но для полноты статьи опишу краткую схему:
- Останавливаем инстанс
- Делаем snapshot EBS тома
- Переходим в наш snapshot, в Actions выбираем “Create volume from snapshot” и дальше главное не забыть поставить галочку над “Encryption” (Use Amazon EBS encryption as an encryption solution for your EBS resources associated with your EC2 instances.)
- Ну все, у нас есть новый том, отключаем старый том, подключаем новый, запускаем машину. Старый том - сжигаем во избежание. (На самом деле - конечно же нет, пока что прибережем во избежание. Но потом - сжечь.)
Сложнаа, нипанятнаа, хочу еще проще!
Можно и еще проще (под $REGION дальше везде будем понимать ваш регион, например, eu-central-1)
aws ec2 enable-ebs-encryption-by-default --region $REGION
Одной командой включаем шифрование для всех НОВЫХ томов в регионе. Теперь каждый раз при создании нового тома (нового EC2 инстанса), мы получаем шифрование автоматически и без усилий.
Это было бы неплохо сделать в любом случае. Но мы пойдем другим путем!
Сложный путь (аж чуть-чуть сложнее)
Общая схема
Мы будем делать двухуровневую иерархию ключей: Создадим ключ в AWS KMS (CMK - Customer Master Key, наш главный ключ, он будет храниться в AWS, внутри HSM. Он же может называться KMS key) , создадим локально ключ шифрования (он называется DEK, Data Encryption Key), зашифруем его ключом из KMS, настроим MariaDB чтобы она шифровала нашим ключом (расшифровывая его из KMS).
Может возникнуть вопрос - зачем нам два ключа? Почему бы сразу не использовать ключ из KMS для шифрования даннных? Причина в том, что ключ из KMS нельзя извлечь, он всегда хранится внутри HSM (и это очень хорошее качество), а шифрование работает только блоками по 4Kb. Это может быть медленно. К тому же, это не бесплатно (10'000 запросов - $0.04=3). Для сервера, который запрашивает расшифровку ключа раз в месяц при перезагрузке или еще реже, это, считай, бесплатно. Но если мы сами данные будем шифровать - это будет и медленно и дорого. Наш путь - правильный, шифруем все локально и своим ключом, но вот сам ключ шифруем ключом из KMS, получая encryption at rest.
Подготовка
Бэкапы, бэкапы, бэкапы! Сделайте снапшот диска заранее. Сделайте SQL дамп всех баз.
sudo mysqldump --all-databases > /root/backup_before_encryption_$(date +%Y%m%d).sql
И забронируйте возвратный билет в какую-нибудь страну, с которой нет договора об экстрадиции. Скорее всего, все получится, но лучше перестраховаться.
Настройка облака: ключ и роли
Переходим в KMS и в Customer managed keys создаем SYMMETRIC_DEFAULT ключ для “Encrypt and decrypt”. Скопируем себе его ARN, скоро он нам пригодится.
Дальше нам нужно дать серверу роль, чтобы он мог иметь доступ к этому ключу. В IAM / Roles создаем роль, назовем ее DB-KMS-Access. В trust relationships внесем:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "ec2.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
}
А в саму роль добавим inline policy (укажите ARN вашего ключа в ней):
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"kms:ListKeys",
"kms:ListAliases",
"kms:DescribeKey",
"kms:GenerateDataKey",
"kms:Decrypt",
"kms:Encrypt",
"kms:ReEncrypt*",
"kms:CreateGrant"
],
"Resource": [
"*",
"arn:aws:kms:---"
]
}
]
}
Применяем роль к серверу, даем может пару минут настояться и проверяем (с сервера):
# aws sts get-caller-identity
{
"UserId": "...",
"Account": "...",
"Arn": "arn:aws:sts::.../DB-KMS-Access/..."
}
Если увидели что-то подобное, роль DB-KMS-Access применилась! Мы можем работать с KMS ключом. Сейчас мы это проверим.
# посмотрим список ключиков (не сами ключи, конечно - их не увидеть)
aws kms list-keys --region $REGION
# посмотрим список алиасов
aws kms list-aliases --region eu-south-1
Дальше сохраним наш ключ (его key-id) в переменную:
export KEYID=89...-...-...-...-...6a56
Давайте уже пошифруем чего-нибудь:
aws kms decrypt \
--ciphertext-blob fileb://<(aws kms encrypt \
--key-id $KEYID \
--plaintext "test" \
--region eu-south-1 \
--output text \
--query CiphertextBlob | base64 --decode) \
--region eu-south-1 \
--output text \
--query Plaintext | base64 --decode && echo ""
Если на выходе получили test - то у нас все получилось! мы и зашифровали и расшифровали обратно.
Настройка сервера и службы дешифрования
Теперь создадим наш ключ шифрования (DEK). На пару минут он будет открытый, но быстро это исправим:
sudo mkdir -p /etc/mysql/encryption
echo "1;$(openssl rand -hex 32)" | sudo tee /etc/mysql/encryption/keyfile
sudo chmod 600 /etc/mysql/encryption/keyfile
sudo chown mysql:mysql /etc/mysql/encryption/keyfile
aws kms encrypt --key-id $KEYID --region $REGION --plaintext fileb:///etc/mysql/encryption/keyfile --output text --query CiphertextBlob | base64 --decode | sudo tee /etc/mysql/encryption/keyfile.enc > /dev/null
sudo rm /etc/mysql/encryption/keyfile
Мы создали ключ, но так как хранить его рядом с данными нельзя, мы зашифровали его и удалили. Теперь у нас есть зашифрованный ключ, который можно расшифровать только на этой машине.
Убедимся:
aws kms decrypt \
--ciphertext-blob fileb:///etc/mysql/encryption/keyfile.enc \
--region eu-south-1 \
--output text \
--query Plaintext | base64 --decode
Мы должны увидеть наш DEK в расшифрованном виде. Эта операция успешно сработает только в AWS, в вашем аккаунте и только на машине, которой вы дали роль доступа к этому ключу.
Создадим systemd юнит, который будет автоматически расшифровывать наш ключ (только в оперативной памяти) /etc/systemd/system/mysql-encryption-key.service:
# /etc/systemd/system/mysql-encryption-key.service
[Unit]
Description=Decrypt MariaDB encryption key from AWS KMS
Before=mariadb.service
After=network-online.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStartPre=/bin/mkdir -p /run/mysql-encryption
ExecStartPre=/bin/mount -t tmpfs -o size=1m tmpfs /run/mysql-encryption
ExecStart=/bin/bash -c 'aws kms decrypt --ciphertext-blob fileb:///etc/mysql/encryption/keyfile.enc --region YOUR_REGION --output text --query Plaintext | base64 --decode > /run/mysql-encryption/keyfile'
ExecStartPost=/bin/chown mysql:mysql /run/mysql-encryption/keyfile
ExecStartPost=/bin/chmod 600 /run/mysql-encryption/keyfile
ExecStop=/bin/umount /run/mysql-encryption
[Install]
WantedBy=multi-user.target
Сделаем, чтобы этот сервис стартовал точно раньше mariadb:
sudo mkdir -p /etc/systemd/system/mariadb.service.d/
sudo tee /etc/systemd/system/mariadb.service.d/wait-for-key.conf << 'EOF'
[Unit]
After=mysql-encryption-key.service
Requires=mysql-encryption-key.service
EOF
sudo systemctl daemon-reload
Убедимся что порядок старта задан:
# systemctl cat mariadb.service | grep -E "After|Requires|Wants"
After=network.target
After=mysql-encryption-key.service
Requires=mysql-encryption-key.service
# systemctl list-dependencies mariadb.service | grep encryption
● ├─mysql-encryption-key.service
Если все так - ребутим сервак и после загрузки:
# cat /run/mysql-encryption/keyfile
...
# ls -al /run/mysql-encryption/keyfile
-rw------- 1 mysql mysql 67 Jun 23 20:38 /run/mysql-encryption/keyfile
Вы должны увидеть что расшифрованный ключ есть, он доступен только mysql:mysql а еще:
# mount | grep mysql-encryption
tmpfs on /run/mysql-encryption type tmpfs (rw,relatime,size=1024k)
Он не на диске, а в tmpfs в оперативной памяти.
Перед тем как мы начнем шифровать базу, давайте сначала убедимся, как плохо иметь нешифрованную базу данных:
# перейдем в каталог любой базы данных
cd /var/lib/mysql/DBNAME
strings *.ibd
увидим огромное количество разных строковых данных, то что у вас хранится. Имена, емейлы, IP адреса, хеши паролей. Любой, кто получит этот нешифрованный файл, получит все это. Вот с этим мы и будем бороться :-)
Делаем файл /etc/mysql/mariadb.conf.d/99-encryption.cnf
[mysqld]
# File Key Management Plugin
plugin-load-add = file_key_management
file_key_management_filename = /run/mysql-encryption/keyfile
file_key_management_encryption_algorithm = AES_CBC
# InnoDB encryption
innodb_encrypt_tables = ON
innodb_encrypt_log = ON
innodb_encryption_threads = 4
перегружаем СУБД:
systemctl restart mariadb
(если все хорошо, на этом этапе серверу может чуть поплохеть - он запустит шифрование в 4 потока).
Еще убеждаемся:
# mysql -e "SHOW PLUGINS;" | grep -i file_key
file_key_management ACTIVE ENCRYPTION file_key_management.so GPL
# mysql -e "SELECT * FROM information_schema.INNODB_TABLESPACES_ENCRYPTION;"
последняя команда выдаст состояние всех InnoDB таблиц, смотрите на колонку ENCRYPTION_SCHEME. Со временем все таблицы должны там получить 1.
В journalctl -u mariadb можно увидеть строки:
Jun 23 20:48:43 xxx mariadbd[1831]: 2026-06-23 20:48:43 0 [Note] InnoDB: Creating #1 encryption thread id 281472879546624 total threads 4.
Jun 23 20:48:43 xxx mariadbd[1831]: 2026-06-23 20:48:43 0 [Note] InnoDB: Creating #2 encryption thread id 281472862761216 total threads 4.
Jun 23 20:48:43 xxx mariadbd[1831]: 2026-06-23 20:48:43 0 [Note] InnoDB: Loading buffer pool(s) from /var/lib/mysql/ib_buffer_pool
Jun 23 20:48:43 xxx mariadbd[1831]: 2026-06-23 20:48:43 0 [Note] InnoDB: Creating #3 encryption thread id 281472854368512 total threads 4.
Jun 23 20:48:43 xxx mariadbd[1831]: 2026-06-23 20:48:43 0 [Note] InnoDB: Creating #4 encryption thread id 281472845975808 total threads 4.
так что, сервер может подзависнусть, дайте ему время осознать и принять свое новое состояние. :-)
Однако, это еще не все. Только новый движок (InnoDB) умеет работать с шифрованными таблицами. А вот старый MyISAM - Нет. Проверим, сколько у нас таких таблиц в каждой базе:
# mysql -e "SELECT TABLE_SCHEMA, COUNT(*) as cnt
FROM information_schema.TABLES
WHERE ENGINE='MyISAM'
AND TABLE_SCHEMA NOT IN ('information_schema','mysql','performance_schema')
GROUP BY TABLE_SCHEMA
ORDER BY cnt DESC;"
+--------------+-----+
| TABLE_SCHEMA | cnt |
+--------------+-----+
| db1 | 104 |
| db2 | 89 |
| another_db | 89 |
| andanother | 28 |
| one_mor | 8 |
+--------------+-----+
Много. Будем исправлять (Внимание - может потребоваться место на диске чтобы поместились одновременно и старые таблицы и новые, к тому же InnoDB займут больше места - учтите это):
mysql -N -e "SELECT CONCAT('ALTER TABLE \`', TABLE_SCHEMA, '\`.\`', TABLE_NAME, '\` ENGINE=InnoDB;')
FROM information_schema.TABLES
WHERE ENGINE='MyISAM'
AND TABLE_SCHEMA NOT IN ('information_schema','mysql','performance_schema');" \
| mysql
и на этом этапе можно пойти погулять - это займет какое-то время. В общем-то и все.
FTS
После конвертации в InnoDB в /var/lib/mysql может появиться много файлов FTS_*.ibd. Это данные для full-text search. И они тоже зашифрованы:
# mysql -e "SELECT NAME, ENCRYPTION_SCHEME
FROM information_schema.INNODB_TABLESPACES_ENCRYPTION
WHERE NAME LIKE '%FTS%' LIMIT 5;"
+---------------------------------------------------+-------------------+
| NAME | ENCRYPTION_SCHEME |
+---------------------------------------------------+-------------------+
| dbname/FTS_00000000000020f2_BEING_DELETED | 1 |
| dbname/FTS_00000000000020f2_BEING_DELETED_CACHE | 1 |
| dbname/FTS_00000000000020f2_CONFIG | 1 |
| dbname/FTS_00000000000020f2_DELETED | 1 |
| dbname/FTS_00000000000020f2_DELETED_CACHE | 1 |
+---------------------------------------------------+-------------------+
У меня они суммарно весят 5 мегабайт. (В MyISAM все данные были внутри файлов таблиц, в InnoDB вынесены отдельно). Не стоит паниковать. Строчки про DELETED тоже пусть не смущают - в InnoDB FTS индекс не перестраивается при удалении записи (это дорого), вместо этого хранится информация об удаленных записях, чтобы они не находились. Все ОК в общем.
Проверим?
mysql -e "
CREATE DATABASE IF NOT EXISTS test_enc;
USE test_enc;
CREATE TABLE secret_data (
id INT AUTO_INCREMENT PRIMARY KEY,
secret_text VARCHAR(255)
) ENGINE=InnoDB;
INSERT INTO secret_data (secret_text) VALUES
('password123'),
('my secret data'),
('confidential info');
SELECT * FROM test_enc.secret_data;
"
А дальше сделайте strings на этот файл, и вы не увидите там ни password123, ни confidential info, только мусор вроде:
REt
Ul
D_
Данные - шифруются. Ура! :-)
Что можно улучшить?
По-хорошему, я бы еще и удалял /run/mysql-encryption/keyfile через какое-то время после запуска MariaDB. Он читается только при старте. Тогда даже получив возможность чтения файлов от mysql или даже root (но без RCE) никто не сможет DEK ключ. Можно просто через cron удалять этот файл если он старше 5 минут.
От чего это НЕ защищает?
Это не защитит данные от:
- Атак через SQL injection
- От RCE атак на любого пользователя (особенно если атакованный пользователь может прочитать пароль к аккаунту). Кроме того, при RCE можно и расшифровать зашифрованный файл через KMS (так же как делает наш systemd юнит).
Зачем так сложно?
На самом деле, в большинстве случаев, и для формального соблюдения требования Encryption at rest и для практически всех рисков - достаточно просто шифрования EBS тома. Наша сложная схема даст преимущество если кто-то через уязвимость на чтение файлов сможет вычитать сырые файлы базы данных (.ibd).
Кроме того, этот способ подойдет как образец для шифрования через собственный HSM (возможно не в AWS), когда шифровать DEK хардварным ключом мы можем, а вот возможности зашифровать весь том у нас нет.