Keyring Plugin 是用来保存敏感信息的插件,目前官方支持了四种插件: keyring_file, , keyring_okv, , 社区版目前只支持 keyring_file 类型,本文基于此类型介绍。

如果要使用表空间加密功能,keyring_file 必须在 MySQL 实例初始化之前初始化(使用 –early-plugin-load 参数),因为 keyring_file 里面保存了解密需要的 master_key。我们可以把这个插件理解成一个 K-V 表,可以根据 key 查找到对应的数据。在源码内部提供了以下几个接口:

用户可以创建 UDF(User Deifined Function) 在 SQL 语句中使用上述接口,作为独立于表空间加密的功能使用,具体使用方式可以参考:General-Purpose Keyring Key_Management Functions。把 keyring_file 放到本地文件显然是不安全的,建议放到类似U盘的地方,启动实例的时候挂载到文件系统,启动之后移除,大概就像原来银行的优盾 :)

为了支持 key rotation,官方的加密用到了两个秘钥,一个是从 kering 生成的 master_key,另一个是用来加密每个表空间的 tablespace_key,master_key 仅仅用来加密解密 tablespace_key。tablespace_key 加密后保存在每个 ibd 文件 page 0 页的尾部,对应图中 Encryption Information 部分,除了 tablespace_key, 还有用来索引 master_key 的 master_key_id 信息,以及 magicnum 和循环冗余校验的数据。关于 InnoDB ibd 文件的页面组织可以参考月报,首个页空间利用并不满。

在 server 层,创建一个加密的表后,加密的信息会保存在 frm 文件中,主要作用是 show create table 时可以打印出加密部分的语句。

InnoDB 层除了 Encryption Information,还会在 ibdata 的字典表中的 flags2 字段标识对应的表示加密表,具体存储在 SYS_TABLES 表 MIX_LEN 列中,关于 InnoDB 字典表结构可以参考这篇文章。在 ibd 文件中会在 page 0 页头部 FSP 的 FLAG 标识这个 File Space 是加密表空间,具体位置在 FSP_FLAGS_POS_ENCRYPTION,如图中 Encryption 所示位置。还会在每个 Index page 的 page type 位置标识这个页是加密的,对应图中 page type 位置。

上述部分是整体架构和物理文件页面有哪些变化,相对于 MySQL 5.6 使用了一些预留的标记为空间,迁移的话保证对应的位置不会冲突。

基础类介绍

img

下面首先介绍一下 master_key 的维护,Encryption 类中有两个静态成员变量,master_key_id 是一个递增的值,每次生成新的 master_key 都会更新,uuid 是当前的 server_uuid。 前面介绍了 keyring plugin 提供的接口函数,在 create_master_key, get_master_key * 2, 三个静态函数中调用。对应 keyring plugin 的 key_id 参数,是 [ENCRYPTION_MASTER_KEY_PRIFIX+uuid+master_key_id] 的字符串组合,用变量 key_name 表示。create_master_key 获得一个新的 master_key:

  1. |
  2. ---- my_key_generate(key_name, "AES", ENCRYPTION_KEY_LEN)
  3. |
  4. ---- my_key_fetch(key_name, &keytype, NULL, master_key, key_len)

get_master_key 是一个重载函数,因为历史原因,为了兼容 5.7.11 版本最初设计的加密格式,需要 uuid 作为一个检索的参数,图中第一个 get_master_key 函数根据传入的参数调用 my_key_fetch 查找,第二个函数的参数都是指针类型,返回之后都会被赋值,如果发现当前的 Encryption::master_key_id 为 0,说明还没有产生过 master_key ,就执行类似 create_master_key 的逻辑创建。

接下来看加密和解密的函数,参数类似,就是传入一个 src 页,然后加密好放到 dst 页中,数据页加密使用的加密算法是 ,这种加密算法要求每个加密块大小是 128 bit(16 Byte),也就是说数据页的大小必须是这个值得整数倍,InnoDB 的页默认大小是 16K,传入整个页进行加密是完全可行的。但是官方选择了最小加密原则,仅仅只对页面中用户数据部分加密,页面头保持明文存储。所以无论是加密还是解密都分成了两次调用,一次对 main_len 大小数据加密,另外一次对 remain_len 加密:

  1. data_len = src_len - FIL_PAGE_DATA;
  2. main_Len = (data_len / MY_AES_BLOCK_SIZE) * MY_AES_BLOCK_SIZE;
  3. remain_len = data_len - main_len;

加密部分会修改页面的 page type 字段,从 src 页固定位置取出当前的页类型,如果是加密过的,就报错,加密结束之后根据原有的页面类型,修改为对应的加密页类型:

  1. if (page_type == FIL_PAGE_COMPRESSED) {
  2. mach_write_to_2(dst + FIL_PAGE_TYPE,
  3. FIL_PAGE_COMPRESSED_AND_ENCRYPTED);
  4. } else if (page_type == FIL_PAGE_RTREE) {
  5. /* If the page is R-tree page, we need to save original
  6. type. */
  7. mach_write_to_2(dst + FIL_PAGE_TYPE, FIL_PAGE_ENCRYPTED_RTREE);
  8. } else{
  9. mach_write_to_2(dst + FIL_PAGE_TYPE, FIL_PAGE_ENCRYPTED);
  10. mach_write_to_2(dst + FIL_PAGE_ORIGINAL_TYPE_V1, page_type);
  11. }

其中 R-tree 是 MySQL 为了支持 GIS 引入的数据类型。WL#6968.解密开始会判断 page type 是否是压缩类型,需要修改src_len 的大小。

Tablespace key 初始化和读取

Tablespace key 是真正用来加密用户页面数据的,就是上节介绍的 Encryption 类的 m_key,tablespace key 会在 create 一个加密表或者 alter 一个表变为加密表时创建,以 create table 流程为例,因为只支持独立表空间,所以需要递增的产生一个 space id,然后创建一个 ibd 文件,并且初始化为默认的 page 数量大小,把 space id 和 fsp flags 写入第一个页的头部(fsp flags 对应整体架构图中 Encryption 部分,标记一个表空间为加密表空间,flags 由 dict_table_t 结构的 flag 转化而来,而 dict_table_t 的 flag 是从 server 层的 TABLE_SHARE 中获得,源头就是用户执行的 create table 语句语法), 创建 file_space_t,初始化 file_node(虽然表空间只有一个 ibd 文件),接着调用 fil_set_encryption 函数生成 tablespace key,并且保存在刚刚创建的 fil_space_t 中。最终会在 fsp_head_init 函数中把 tablespace key 相关信息写入页面中,对应整体架构图中 Encryption Infomation 部分。函数调用栈:

重点看一下 fil_set_encryption 是怎么生成 tablespace key 的,还有具体保存在页面中都有哪些东西。上图调用栈最后两个参数为 NULL,对应的就是需要生成的 tablespace key 和加密向量 iv,因为是表空间刚刚创建,NULL 表示需要生成。首先判断是不是系统表空间 is_system_tablespace(space_id) 对于系统表空间不进行加密处理,如果 key 和 iv 为 NULL, 就调用 Encryption::random_value 产生一个随机的值,对于 key 和 iv 都一样,Encryption::random_value 最终会调用 YaSSL/OpenSSL 的 RAND_bytes 函数产生随机值,如果 key 和 iv 不为 NULL,就不必产生,最终赋值到 fil_space_t 中变量即可返回。

真正把 tablespace 加密并且写入到页中的是函数 fsp_header_fill_encryption_info, 从 Encryption::get_master_key 拿到加密的 master_key,从 fil_space_t 中拿到明文的 tablespace key 和 iv,使用 my_aes_256_ecb 加密算法进行加密,ecb 加密算法相对于 cbc 具有更高的安全度,当前开销也更大,看来官方也意识到把 tablespace key 放到文件里存在一定的安全隐患。存储的格式分为两种,是高版本为了向下兼容:

  • ENCRYPTION_INFO_V1: magic number + master_key_id + key + iv + checksum
  • ENCRYPTION_INFO_V1: magic number + master_key_id + key + iv + server_uuid + checksum

接下来介绍打开一张已经加密的表,tablespace key/iv 是如何初始化的,直接来看下调用堆栈:

  1. fil_ibd_open
  2. |
  3. --- Datafile.validate_to_add
  4. | |
  5. | --- Datafile::validate_first_page
  6. | |
  7. | --- fsp_header_get_encryption_key
  8. | |
  9. | --- fsp_header_get_encryption_offset
  10. | |
  11. |
  12. --- fil_space_create
  13. |
  14. --- fil_set_encryption

正常打开一张表首先会根据表名去 ibdata 的字典表里查找元数据信息,例如文件路径,dict_table_t 的 flags/flags2 等等,接下来就是调用 fil_ibd_open 打开文件, 并且做一系列的校验,Datafile 是用来维护文件信息的类,在 validate_first_page 中会根据 flag 判断是否加密表空间,如果是的话,就读出第一个页,传给 fsp_header_get_encryption_key 函数,在函数里首先计算偏移,然后交给 fsp_header_decode_encryption_info 对 key/iv 进行解密。fsp_header_decode_encryption_info 首先校验 magic number,然后读出 master_key_id 和 server_uuid,用来查找 master_key, 然后用 master_key 解密 tablespace key 得到明文,最后一步是用明文再做一次循环冗余校验,和保存的 checksum 对比,值是否相同。至此已经得到了正确的 tablespace key。接着创建 fil_space_t 然后把明文的 key/iv 放进去,以备后面 IO 使用。

IO 路径解析

InnoDB 的 IO 分为同步 IO 和 异步 IO,同步 IO 调用操作系统的 pwrite/pread 函数,异步 IO 又分为 simulate IO 和 Linux native aio, 关于 IO 的详细介绍可以参考 , InnoDB IO 子系统, 三篇月报。这里介绍一些加密是在哪里 IO 路径中的,首先是同步 IO 路径, 以 write 为例,read 类似。在 fil_io 中初始化 IORequest 类中的 encryption 相关信息,根据要读写的 page id, fil_space。

  1. fil_io
  2. |
  3. --- fil_io_set_encryption
  4. |
  5. | |
  6. | --- os_file_write_pfs
  7. | |
  8. | --- os_file_write_func
  9. | |
  10. | --- os_file_write_page
  11. | |
  12. | --- os_file_pwrite
  13. | |
  14. | --- os_file_io
  15. | |
  16. | --- os_file_encrypt_page
  17. | |
  18. --- os_file_read --- Encryption::encrypt
  19. |
  20. --- ......
  21. |
  22. --- os_file_io
  23. |
  24. --- os_file_io_complete
  25. |
  26. --- Encryption::decrypt

异步 IO 无论是 simulate IO 还是 native aio, 都是把请求放到一个 slot 里,由后台异步线程去刷盘, 发起 IO 请求的入口函数是 os_aio_func, 对于同步读写请求(OS_AIO_SYNC),发起请求的线程直接调用os_file_read_func 或者os_file_write_func 去读写文件,然后返回。对于异步请求,用户线程从对应操作类型的任务队列(AIO::select_slot_array)中选取一个slot,将需要读写的信息存储于其中(AIO::reserve_slot), 对于 write 操作,此时把需要写入的数据进行加密。对于Native AIO(使用linux自带的LIBAIO库),调用函数AIO::linux_dispatch,将IO请求分发给kernel层。

  1. fil_io
  2. |
  3. --- fil_io_set_encryption
  4. |
  5. ---os_aio
  6. |
  7. --- os_aio_func
  8. |
  9. --- AIO::reserve_slot
  10. | |
  11. | --- os_file_encrypt_page
  12. | --- Encryption::encrypt
  13. |
  14. --- linux_dispatch(slot)

处理异步 IO 请求的入口函数是 fil_aio_wait, 对于 Native AIO,调用函数os_aio_linux_handle 获取读写请求。IO线程会反复以500ms(OS_AIO_REAP_TIMEOUT)的超时时间通过io_getevents确认是否有任务已经完成了(LinuxAIOHandler::collect()),如果有读写任务完成,找到已完成任务的slot后,释放对应的槽位,写请求已经加密过,直接写入即可,读请求需要进行解密,调用堆栈如下。

Master key rotation

Master_key 对于整个实例加密非常重要,官方加密方法最重要的一个特性就是可以更新 master_key, 因为 tablespace_key 的明文不会变,更新 master_key 之后只需要把 tablespace_key 重新加密写入第一个页中即可。入口是 server 层一个类 Rotate_innodb_master_key::execute 函数,这个类继承 Alter_instance 类,execute 函数会调用 innobase_encryption_key_rotation , 这个函数在引擎初始化(innobase_init)的时候注册到 innobase_hton 中。接着创建一个新的 master_key ,由于明文的 tablesapce key 保存在 fil_space_t 中,无需用原来的 master_key 进行解密。然后 fil_encryption_rotate 遍历 fil_system 中的每一个 fil_space_t ,调用 fsp_header_rotate_encrytion 加密 tablespace_key 并存储。

  1. |
  2. --- innobase_encryption_key_rotation
  3. |
  4. --- Encryption::create_master_key
  5. |
  6. --- fil_encryption_rotate
  7. |
  8. --- fsp_header_rotate_encryption
  9. |
  10. --- fsp_header_fill_encryption_info
  11. |
  12. --- mlog_write_string

Export/ Import

为了支持 Export/Import 加密表,引入了 transfer_key,在 export 的时候随机生成一个 transfer_key, 把现有的 tablespace_key 用 transfer_key 加密,并将两者同时写入 table_name.cfp 的文件中,注意这里 transfer_key 保存的是明文。Import 会读取 transfer_key 用来解密,然后执行正常的 import 操作即可,一旦 import 完成,table_name.cfg 文件会被立刻删除。写 transfer_key 调用栈:

  1. row_quiesce_write_cfp
  2. |
  3. --- row_quiesce_write_transafer_key
  4. |
  5. --- Encryption::random_value(transfer_key)
  6. |
  7. --- my_aes_encrypt(my_aes_256_ecp)

import 调用栈:

  1. row_import_for_mysql
  2. |
  3. --- row_import_read_cfg
  4. | |
  5. | --- row_import_read_encryption_data
  6. | |
  7. | --- fread(table_name.cfp)
  8. | |
  9. | --- my_aes_decrypt
  10. |
  11. --- fil_tablespace_iterate
  12. |
  13. --- fil_iterator
  14. |
  15. --- IORequest::encryption_key
  16. |
  17. --- os_file_read
  18. |

崩溃恢复

在数据库进行崩溃恢复的时候 InnoDB 是无法从字典表取得数据的,也就是说正常判断一个表是不是加密表的路径(Dict_table_t::flags2 -> fil_space_t)是行不通的,所以需要在 ibd 文件的头部标记加密,读取 ibd 就知道表空间类型。对于官方的加密方法,因为有 tablespace_key 的相关信息持久化在页面上,受 redo 保护,所以在崩溃恢复的时候需要能够从 redo 中正确解析,需要增加处理逻辑。对于崩溃恢复的详细介绍可以参考早期月报 Innodb 崩溃恢复过程。这部分相关修改并不多,首先需要构建 recv_sys , 在 recv_sys 结构中增加了一个 encryption_list, 保存需要回复的加密表空间信息, 初始化在 recv_parse_or_apply_log_rec_body 中,如果是 page = 0 的页,并且不是系统表空间,就调用 fil_write_encryption_parse -> fsp_header_decode_encryption_info 进行解析,拿到 master_id, 查找 keyring plugin 得到 master_key, 然后解析 tablespace_key,如果是未加载的表空间,就放到 recv_sys->encryption_list 里面。

官方的这种加密方式优点和缺点都相当明显,优点是 master_key 可以经常更新,能够满足一定的用户需求,并且每个表都可以拥有不同的秘钥 tablespace_key ,即使一张表被破解,其它表也不会立刻丢失数据。缺点也在 tablesapce_key 的存放上,所有的人都知道加密的秘钥保存在哪里,甚至知道明文的 checksum 是什么,对于高安全的用户来说,秘钥不落地是非常重要的,显然官方的这种加密方式无法满足。另外一个很危险的就是 export/import , 虽然重新生成了一个 transfer_key,但是竟然是明文保存在文件里,即使用完就会删除,但是这个时间间隙被利用就相当危险了。