column_encrypt: PG 推出列级加密插件
column_encrypt: 列级加密“应用无感、密钥可轮换、SQL 可继续写”
https://github.com/vibhorkum/column_encrypt
很多企业说“数据库要加密”,其实说的不是一个问题,而是一组互相牵制的问题:
数据落盘后不能是明文。 应用最好不要大改 SQL。 DBA 最好不能随便看到敏感字段。 密钥不能明文放数据库里。 将来要能轮换密钥。 还要能查等值条件,比如手机号、身份证、邮箱。 逻辑复制、备份恢复、审计日志都不能把明文泄漏出去。
这几条放在一起,就会发现:列级加密不是“调用一下 AES 函数”这么简单。
column_encrypt 这个 PostgreSQL 扩展的定位,是提供透明列级加密。它提供 encrypted_text 和 encrypted_bytea 两个自定义类型,在类型输入/输出层完成加密和解密,让应用在插入、更新、查询时尽量保持原来的 SQL 形态。
先说结论:
column_encrypt真正解决的不是“有没有 AES”,而是把列级加密需要的类型封装、密钥加载、版本头、密钥轮换、日志脱敏、权限控制、逻辑复制边界和等值查询模式,打包成一套 PostgreSQL 内部可运行的工程方案。
一、背景:为什么列级加密越来越刚需?
PostgreSQL 本身可以做很多层面的安全防护:
网络层 TLS。 磁盘层 LUKS、云盘加密、文件系统加密。 权限层 GRANT、RLS、schema 隔离。 审计层 pgaudit、日志审计。 备份层加密。 应用层自行加密敏感字段。
这些方案各有价值,但都不能完全替代列级加密。
原因很简单:敏感数据的风险不是只发生在磁盘丢失时。
真实生产里,泄漏路径包括:
DBA 或运维直接查询敏感字段。 备份文件被复制走。 逻辑复制链路把明文带到下游。 测试环境使用了生产数据。 SQL 日志记录了 INSERT/UPDATE 明文。 应用 bug 把不该返回的字段返回了。 数据库超级权限、运维脚本、ETL 任务扩大了明文暴露面。
如果只做磁盘加密,数据库进程读出来仍然是明文;如果只靠权限控制,高权限账号仍然能看到;如果只靠应用层加密,应用改造成本和密钥治理成本都不低。
所以列级加密的核心目标是:
敏感列在数据库存储层以密文存在,只有持有正确密钥的会话才能透明读写明文。
column_encrypt 就是沿着这个方向设计的。
二、痛点:传统 PostgreSQL 敏感字段保护方案各有短板
1. 磁盘加密保护不了数据库内部明文
云盘加密、LUKS、TDE 类方案主要解决“存储介质丢失”问题。
它们能防:
磁盘被拔走。 快照文件泄漏。 底层块设备被复制。
但防不了:
数据库超级用户查询。 应用误查。 SQL 日志明文。 逻辑复制输出明文。 备份逻辑导出明文。
因为 PostgreSQL 进程看到的数据仍然是明文。
2. 权限控制保护不了高权限路径
你可以不给普通用户访问敏感列,也可以用 view、RLS、函数封装。
但问题是:
超级用户天然绕过很多权限边界。 DBA 常常需要排障权限。 ETL、BI、审计账号容易被授权过宽。 权限模型会随着业务增长变复杂。
权限控制是必要的,但不是加密。
3. 应用层加密改造成本高
应用层加密的安全边界最清晰:数据库只存密文,密钥不进数据库。
但代价也明显:
每个应用都要改加解密逻辑。 多语言、多服务实现容易不一致。 SQL 查询能力下降。 历史数据迁移麻烦。 密钥轮换要应用和数据层协同。 字段类型、ORM、序列化、索引策略都要改。
如果只是少数字段还好;如果是多个业务系统、多个表、多个团队,工程复杂度会迅速上升。
4. 手写 pgcrypto 函数容易变成“半成品加密”
PostgreSQL 有 pgcrypto,可以自己写:
INSERTINTO t(ssn) VALUES (pgp_sym_encrypt('888-999-2045', 'passphrase'));
SELECT pgp_sym_decrypt(ssn, 'passphrase') FROM t;
但这样会引出一串问题:
应用 SQL 要改。 密钥容易出现在 SQL 文本、日志、连接池、审计里。 不同字段、不同表的封装难统一。 密钥版本怎么记录? 轮换怎么批量重加密? 等值查询怎么做? 权限怎么收口? 错误处理怎么规范?
pgcrypto 是密码学工具箱,不是完整列级加密产品。
三、column_encrypt 的方案:自定义类型 + 会话密钥 + 版本化密文
column_encrypt 的核心设计是两个自定义 base type:
encrypted_textencrypted_bytea
它们底层使用变长 bytea 存储。
关键点在于类型 I/O:
INSERT/UPDATE时,类型 input function 自动把明文加密成密文。SELECT时,类型 output function 自动把密文解密成明文。应用 SQL 可以继续像写普通 text 一样写入值。 前提是当前 session 已经加载了正确密钥。
这相当于把加解密逻辑放进 PostgreSQL 类型系统。
示意:
应用 SQL: INSERT INTO secure_data(ssn) VALUES('888-999-2045')
|
v
encrypted_text input function
|
v
使用 session 中加载的 DEK 加密
|
v
存储: key version header + ciphertext
SELECT ssn FROM secure_data
|
v
encrypted_text output function
|
v
根据 ciphertext header 找 key version
|
v
使用 session keyring 解密并输出明文
这个设计的产品价值是:
应用看到的是类似普通列的使用体验,数据库里存的是带 key version header 的密文。
四、两层密钥模型:KEK 包 DEK,DEK 不明文落库
README 中明确说明 column_encrypt 使用两层密钥:
cipher_key_table,会话加载后进入内存 | ||
注册密钥时:
SELECT encrypt.register_key('my-secret-data-key', 'my-master-passphrase');
它会用 master passphrase 通过 pgcrypto 的 pgp_sym_encrypt 包裹 DEK。README 说明使用 AES-256/S2K:
cipher-algo=aes256, s2k-mode=3
使用时,每个 session 需要加载 key:
SELECT encrypt.load_key('my-master-passphrase');
加载后,DEK 存在 backend 的 C session memory 中。README 说明 keys 保存在 TopMemoryContext 的 versioned in-memory keyring,卸载时用 secure_memset 清零。
卸载:
SELECT encrypt.unload_key();
这个模型的优点是:
数据库表里不存明文 DEK。 应用 session 必须持有 passphrase 才能解密。 同一 session 可以加载多个 key version,支持轮换。 key version 和 ciphertext 绑定,便于历史数据解密和重加密。
五、密文头:2 字节 key version 是轮换的基础设施
列级加密最容易被低估的问题是 key rotation。
如果密文里没有版本信息,几年后你会遇到一个麻烦:
这行数据到底是用哪把 key 加密的?
column_encrypt 在每个 ciphertext 前加 2 字节 key version header。
README 中的格式:
bit 15,也就是 0x8000,设置为 flag。low 15 bits 存 key version。 key version 范围 1-32767。 网络字节序,保证跨平台兼容。 旧格式数据可 fallback 自动识别。
这看似只是 2 字节,但工程意义很大:
解密时能按 header 找对应版本 key。 轮换时可以知道哪些行还没重加密。 多版本 keyring 可以平滑过渡。 将来审计、验证、迁移都有依据。
没有版本头的加密,通常后期都会在轮换上还债。
六、安全模型:不是只加密,还要收住执行权限和日志
1. 单一角色 column_encrypt_user
README 中 v4.0 采用统一角色:
GRANT column_encrypt_user TO app_user;
GRANT column_encrypt_user TO key_manager;
所有 encrypt.* 函数都是 SECURITY DEFINER,并且从 PUBLIC revoke。直接访问 cipher_key_table 也从 PUBLIC revoke。
这让权限边界更清楚:
不是所有数据库用户都能注册、加载、轮换密钥。 应用角色需要显式获得 column_encrypt_user。密钥管理函数不暴露给 PUBLIC。
2. encrypt.enable 是 superuser-only
encrypt.enable 是 PGC_SUSET,只有 superuser 能改。
这点很关键。否则普通用户如果能关闭 encryption,就可能绕过输入/输出路径的安全逻辑。
3. 自动日志脱敏
column_encrypt 使用 emit_log_hook 对敏感 key-management function call 做日志脱敏。
相关 GUC:
encrypt.mask_key_log = on
这是 defense-in-depth,防止注册 key、加载 key 时 passphrase 出现在 PostgreSQL 日志里。
但 README 也特别提醒:默认情况下,普通 INSERT/UPDATE 里的 plaintext values 仍可能出现在 PostgreSQL 日志里。
生产建议开启:
SET encrypt.mask_query_literals = on;
或全局配置:
encrypt.mask_query_literals = on
效果是把字符串 literal 脱敏,例如:
INSERTINTO t(ssn) VALUES('123-45-6789')
日志里变成:
INSERTINTO t(ssn) VALUES('***')
代价是所有 string literal 都被遮蔽,日志可观测性下降。
这是典型安全权衡:
生产敏感数据系统,日志可读性要让位于明文泄漏风险。
4. 禁止 binary protocol SEND/RECEIVE
README 明确说 binary protocol SEND / RECEIVE 被有意拒绝,避免客户端绕过 text I/O 加密路径。
这是一个容易被忽略但很重要的边界。
如果自定义类型只在 text I/O 做加解密,而 binary I/O 可以直接传底层 bytes,就可能出现绕过。这个扩展选择直接拒绝 binary protocol,是更保守的安全设计。
七、等值、哈希和索引:加密列最难的是“还能不能查”
加密字段一旦进入数据库,就会遇到查询问题。
1. equality/hash 基于解密明文
README 说明 equality 和 hash 语义定义在 decrypted plaintext 上,而不是 raw ciphertext bytes 上。
这解决一个关键问题:
同一个明文经过不同 key version 加密后,ciphertext 不同,但逻辑上应该相等。
如果比较 raw ciphertext,密钥轮换后同一值会失去相等语义。
如果比较 decrypted plaintext,相等判断在逻辑上是正确的。
但是代价也明显:
比较需要可用 session key。 大规模索引等值查找不适合直接依赖解密比较。 hash index 语义曾经变化过,README 提醒从 v2.x 升级后要重建 hash index。
2. range ordering 不支持
README 明确说 range ordering on encrypted values intentionally unsupported。
也就是不要指望:
WHERE encrypted_col > 'abc'
ORDER BY encrypted_col
这是正确取舍。
如果加密后还想保留范围顺序,通常意味着使用 order-preserving / order-revealing encryption,这会泄漏顺序信息,安全模型完全不同。
column_encrypt 选择不支持范围排序,是偏安全和明确边界的设计。
3. 推荐 blind index 做可扩展等值查询
README 推荐 companion blind-index column。
示例:
ALTERTABLE secure_data ADDCOLUMN ssn_blind_index text;
UPDATE secure_data
SET ssn_blind_index = encrypt.blind_index('888-999-2045', 'blind-index-secret');
更实际的表设计应该是:
CREATETABLE secure_data (
id bigserial PRIMARY KEY,
ssn encrypt.encrypted_text,
ssn_bidx text
);
CREATEINDEXON secure_data(ssn_bidx);
插入时同时写:
INSERTINTO secure_data(ssn, ssn_bidx)
VALUES (
'888-999-2045',
encrypt.blind_index('888-999-2045', 'blind-index-secret')
);
查询时:
SELECTid, ssn
FROM secure_data
WHERE ssn_bidx = encrypt.blind_index('888-999-2045', 'blind-index-secret');
这里的思想是:
加密列负责保密存储。 blind index 负责等值定位。 blind-index key 要和 DEK/KEK 分开管理。
注意:blind index 适合 equality lookup,不适合范围查询,也会泄漏相同值出现的频率。比如同一个手机号出现多次,blind index 也会相同。
八、密钥轮换:这个扩展的工程成熟度主要看这里
README 给出的轮换流程:
-- Step 1: Register a new key version (inactive by default)
SELECT encrypt.register_key('new-data-key', 'my-master-passphrase', false);
-- Step 2: Load all key versions for rotation
SELECT encrypt.load_key('my-master-passphrase', all_versions => true);
-- Step 3: Activate the new version and re-encrypt
SELECT encrypt.activate_key(2);
SELECT encrypt.rotate('public', 'secure_data', 'ssn');
-- For large tables, use a smaller batch_size
SELECT encrypt.rotate('public', 'secure_data', 'ssn', 5000);
-- Step 4: Clear the session keyring
SELECT encrypt.unload_key();
这里要看懂几个关键点:
1. 新 key 可以先注册为 inactive
SELECT encrypt.register_key('new-data-key', 'my-master-passphrase', false);
这允许你先准备好 key,不立即影响新写入。
2. 轮换时要加载所有版本
SELECT encrypt.load_key('my-master-passphrase', all_versions => true);
因为旧数据需要用旧 key 解密,再用新 active key 加密。
3. activate_key() 设置 active key
SELECT encrypt.activate_key(2);
README 说明它会设置 encrypt.key_version,让后续新加密写入带新版本。
4. rotate() 做整列重加密
SELECT encrypt.rotate('public', 'secure_data', 'ssn', 5000);
batch_size 控制内部 UPDATE chunk size,降低单次 UPDATE 锁持有时间。
但要注意:README 也说 rotate 仍是在一次函数调用里处理整个 column,batch_size 是内部 chunk。
生产大表轮换要额外关注:
表膨胀。 WAL 放大。 复制延迟。 autovacuum 压力。 锁等待。 业务低峰窗口。 回滚风险。
5. 轮换后卸载 key
SELECT encrypt.unload_key();
这会清除 session memory 中的 keyring。
九、逻辑复制:复制密文,不复制明文
README 对逻辑复制的边界讲得很明确:
column_encrypt replicates ciphertext, not plaintext。
这意味着:
subscriber 也要安装 extension。 subscriber 独立管理和加载 keys。 cipher_key_table中 wrapped keys 不会变成 subscriber 可用 session keys。复制/apply role 应该设置 encrypt.enable = off,让复制传输 ciphertext,而不是 decrypted plaintext。
推荐模式:
ALTERROLE replication_user SET encrypt.enable = off;
ALTERROLE subscription_owner SET encrypt.enable = off;
普通应用 session 仍保持:
encrypt.enable = on
并只在需要读写明文的角色中加载 key。
这个设计符合安全直觉:
复制链路应该搬运密文,不应该因为下游同步而扩大明文暴露面。
项目也提供 Docker 逻辑复制 harness:
./run-docker-logical-replication.sh 18
十、效果对比:column_encrypt 改变的是改造成本和密钥治理方式
pgcrypto 手写 | column_encrypt | |||
|---|---|---|---|---|
activate_keyrotate | ||||
一句话:
column_encrypt的价值不是发明加密算法,而是把 PostgreSQL 列级加密的工程坑集中收口。
十一、适合哪些场景?
场景 1:PII 字段保护
典型字段:
身份证号。 手机号。 邮箱。 社保号。 护照号。 银行卡号。 地址。
表设计:
CREATETABLE customer_profile (
id bigserial PRIMARY KEY,
nametext,
mobile encrypt.encrypted_text,
mobile_bidx text,
id_card encrypt.encrypted_text,
id_card_bidx text,
created_at timestamptz DEFAULTnow()
);
CREATEINDEXON customer_profile(mobile_bidx);
CREATEINDEXON customer_profile(id_card_bidx);
写入:
SELECT encrypt.load_key('my-master-passphrase');
INSERTINTO customer_profile(name, mobile, mobile_bidx, id_card, id_card_bidx)
VALUES (
'alice',
'13800000000',
encrypt.blind_index('13800000000', 'mobile-bidx-secret'),
'110101199001010000',
encrypt.blind_index('110101199001010000', 'idcard-bidx-secret')
);
等值查询:
SELECTid, name, mobile, id_card
FROM customer_profile
WHERE mobile_bidx = encrypt.blind_index('13800000000', 'mobile-bidx-secret');
场景 2:多应用共享数据库,但只有部分角色可看明文
给需要读写敏感字段的应用角色授权:
GRANT column_encrypt_user TO app_sensitive;
不给普通分析角色授权,也不让其加载 key。
即使它能查询表,读 encrypted column 也会因为没有 session key 报错。
测试:
SELECT * FROM secure_data;
预期:
ERROR: cannot decrypt data, because key was not set
场景 3:生产数据同步到下游,但下游默认不掌握 key
逻辑复制只传 ciphertext。
下游如果只是做备份、灾备、冷数据归档,不加载 key,就不能读明文。
复制角色配置:
ALTERROLE repl_user SET encrypt.enable = off;
下游需要实际解密时,再由被授权角色加载 passphrase。
场景 4:合规要求定期轮换密钥
轮换步骤:
SELECT encrypt.register_key('new-data-key', 'my-master-passphrase', false);
SELECT encrypt.load_key('my-master-passphrase', all_versions => true);
SELECT encrypt.activate_key(2);
SELECT encrypt.rotate('public', 'secure_data', 'ssn', 5000);
SELECT encrypt.unload_key();
轮换后验证:
SELECT * FROM encrypt.keys();
SELECT encrypt.loaded_cipher_key_versions();
SELECT * FROM encrypt.status();
抽样验证:
SELECT *
FROM encrypt.verify('public', 'secure_data', 'ssn', 100);
十二、实操:从安装到第一张加密表
1. 安装依赖
要求:
PostgreSQL 14+。 pgcrypto。C compiler。 PostgreSQL development headers。
pgcrypto 在 extension control 中作为 dependency,创建扩展时会自动安装。
2. 编译安装
git clone https://github.com/vibhorkum/column_encrypt.git
cd column_encrypt
export PATH=/usr/pgsql-<version>/bin:$PATH
make
make install
3. 配置预加载
修改 postgresql.conf:
shared_preload_libraries = '$libdir/column_encrypt'
重启:
pg_ctl restart -D $PGDATA
或:
systemctl restart postgresql
4. 创建 schema 和 extension
先创建 schema:
CREATESCHEMAIFNOTEXISTSencrypt;
再创建扩展:
CREATE EXTENSION column_encrypt;
5. 设置 search_path
推荐数据库级:
ALTERDATABASE your_database SET search_path TOpublic, encrypt, pg_catalog;
或角色级:
ALTERROLE your_role SET search_path TOpublic, encrypt, pg_catalog;
也可以显式使用 schema-qualified type:
encrypt.encrypted_text
6. 授权应用角色
GRANT column_encrypt_user TO app_user;
GRANT column_encrypt_user TO key_manager;
7. 注册 key
用有 column_encrypt_user 的角色执行:
SELECT encrypt.register_key('my-secret-data-key', 'my-master-passphrase');
8. 每个 session 加载 key
SELECT encrypt.load_key('my-master-passphrase');
可以查看当前 session 加载了哪些 key version:
SELECT encrypt.loaded_cipher_key_versions();
9. 建表并写入
CREATETABLE secure_data (
idserial PRIMARY KEY,
ssn encrypt.encrypted_text
);
INSERTINTO secure_data(ssn) VALUES('888-999-2045');
INSERTINTO secure_data(ssn) VALUES('888-999-2046');
INSERTINTO secure_data(ssn) VALUES('888-999-2047');
读取:
SELECT * FROM secure_data;
预期:
id | ssn
----+--------------
1 | 888-999-2045
2 | 888-999-2046
3 | 888-999-2047
10. 新 session 不加载 key 的行为
SELECT * FROM secure_data;
预期:
ERROR: cannot decrypt data, because key was not set
错误 passphrase:
SELECT encrypt.load_key('wrong-passphrase');
预期:
ERROR: incorrect passphrase
11. 生产建议开启 literal masking
encrypt.mask_query_literals = on
或者 session 级:
SET encrypt.mask_query_literals = on;
注意:这些 GUC 都是 superuser 才能改。
十三、GUC 参数怎么理解?
encrypt.enable | on | ||
encrypt.mask_key_log | on | ||
encrypt.mask_query_literals | off | ||
encrypt.key_version | 1 | activate_key() 管理,不建议手工乱改 |
所有参数都是 PGC_SUSET,需要 superuser 修改。
十四、升级注意事项
README 中强调 v4.0 移除了 deprecated functions。升级到 v4.0 要先经过 v3.3:
ALTER EXTENSION column_encrypt UPDATETO'3.3';
然后按 MIGRATION.md 更新代码到 encrypt.* API,再升级:
ALTER EXTENSION column_encrypt UPDATETO'4.0';
从 v2.x 升级后,如果已经在 encrypted_text 或 encrypted_bytea 上建过 hash index,需要重建。
原因是 equality/hash 语义已转向 decrypted plaintext,不再按 ciphertext bytes 处理。
十五、测试与验证
1. Docker regression
如果不想在主机安装开发包:
./run-docker-regression.sh
指定版本:
./run-docker-regression.sh 14
./run-docker-regression.sh 15
./run-docker-regression.sh 16
./run-docker-regression.sh 17
./run-docker-regression.sh 18
它会:
构建临时 Ubuntu 测试镜像。 安装对应 PostgreSQL server/contrib/dev packages。 执行 make和make install。初始化临时 cluster。 设置 shared_preload_libraries = 'column_encrypt'。创建测试库并运行 make installcheck。
2. 逻辑复制 harness
./run-docker-logical-replication.sh 18
用于验证 publisher/subscriber 的 ciphertext 复制模式。
3. 上线前验收清单
建议至少验证:
无 key session 读取 encrypted column 是否失败。 正确 passphrase 是否能 load key。 错误 passphrase 是否返回 28P01。INSERT/UPDATE 是否实际落密文。 SQL 日志是否按预期脱敏。 blind index 查询是否走索引。 key rotation 后旧数据是否可读。 rotate 期间 WAL、锁、复制延迟是否可接受。 logical replication 是否复制 ciphertext。 备份恢复后 key 管理流程是否仍然可用。
十六、边界条件:不要把列级加密想成魔法
1. session 加载 key 后,查询结果就是明文
只要 session 加载了 key,SELECT 输出就是明文。
所以仍然要控制:
谁能获得 column_encrypt_user。谁知道 passphrase。 应用是否会把明文打日志。 BI/ETL 是否误读敏感列。
列级加密降低存储和非授权 session 的暴露面,不等于消灭所有明文路径。
2. passphrase 管理仍然在数据库外
README 明确 KEK/master passphrase 不存数据库。
这意味着你必须有外部密钥管理方案,例如:
KMS。 Vault。 密钥注入平台。 运维审批流程。 应用启动时安全加载。
扩展不替你解决组织层面的密钥治理。
3. blind index 会泄漏等值频率
blind index 可以查等值,但相同明文会有相同 blind index。
攻击者如果能看到 blind index 分布,可能推断频率模式。
对低基数字段,例如性别、状态、地区,不适合 blind index。
4. 不支持范围查询是有意为之
不要试图在 encrypted column 上做 <、>、ORDER BY。
如果业务强依赖范围查询,应该重新设计数据模型,或者接受更复杂且有泄漏风险的专门加密方案。
5. 超级用户仍是强信任主体
PostgreSQL extension 运行在数据库进程内。超级用户、主机 root、能调试进程的人,仍然属于强信任边界。
这类方案主要降低数据库表、备份、复制、普通账号、误操作日志中的明文暴露,不是硬件安全模块级别的隔离。
十七、结论
column_encrypt 是一个围绕 PostgreSQL 类型系统设计的透明列级加密扩展。
它把敏感字段保护拆成几件工程上必须同时成立的事:
用 encrypted_text、encrypted_bytea封装列类型。用 type input/output 做透明加解密。 用 KEK/DEK 两层模型避免 DEK 明文落库。 用 2 字节 key version header 支撑未来密钥轮换。 用 session keyring 控制谁能读明文。 用 SECURITY DEFINER和column_encrypt_user收口权限。用 emit_log_hook和 literal masking 降低日志泄漏。用 blind index 支撑可扩展等值查询。 用 rotate/verify/status/keys 等函数补齐生命周期管理。 用 ciphertext replication 模式控制逻辑复制明文扩散。
它最适合的场景是:
需要保护 PII、证件号、手机号、邮箱、地址等敏感字段。 应用希望尽量少改 SQL。 数据库需要存密文,但授权 session 可以透明读写。 合规要求支持密钥轮换。 下游复制、备份、测试环境不应默认持有明文。
它不适合的场景是:
强依赖加密字段范围查询。 无法接受 session 加载 key 后输出明文。 没有外部 passphrase/KMS 管理能力。 希望靠一个扩展解决所有权限、审计、应用泄漏问题。
最后一句话:
列级加密的难点不是 AES,而是密钥生命周期、SQL 改造成本、查询能力、安全边界和运维流程。
column_encrypt的价值,就是把这些工程问题尽量放进 PostgreSQL 的类型、函数、GUC 和角色体系里,让敏感列从“靠约定保护”变成“靠机制保护”。