编程 AES-256-GCM 字段加密后 WHERE email=? 查不到:用 HMAC 盲索引补搜索,以及 4 个上线前缺口

2026-10-20 00:04:37

AES-256-GCM 字段加密后 WHERE email = ? 查不到:用盲索引补上搜索,顺便说清哪些列不该加密

把一个用户表里的 email 列加密之后,最常见的第一个报错不是解密失败,而是登录接口查不到人。代码逻辑没动,SQL 还是 WHERE email = ?,可传入明文邮箱永远匹配不上密文列。更隐蔽的是唯一约束:UNIQUE(email) 改成 UNIQUE(email_enc) 之后,同一个邮箱注册两次都能成功,因为每次加密用的 nonce 不同,相同明文产出的密文完全不同。

这不是实现写错了,是随机 IV 的必然结果。AES-256-GCM 每条记录用独立 nonce 是安全要求,代价就是密文失去可比性。要恢复等值查询,得在密文列之外单独维护一个确定性的索引列。

参考实现来自 NENE2 的操作指南:,FT 参考为 FT267(NENE2-FT/encryptlog),同一模式在 FT187 encryptlog 中也验证过。

密文格式与表结构

每个字段加密后按 base64( nonce ‖ ciphertext ‖ tag ) 存进一个 TEXT 列:

  • nonce:12 字节,每次加密随机生成的 IV(GCM 标准)
  • ciphertext:可变长度
  • tag:16 字节认证标签,用于检测篡改

搜索不碰密文列,而是碰一个 HMAC-SHA256 盲索引列:

CREATE TABLE vault_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
name_enc TEXT NOT NULL,   -- base64(nonce || ciphertext || tag)
email_enc TEXT NOT NULL,
email_idx TEXT NOT NULL,  -- HMAC-SHA256 盲索引,用于搜索
notes_enc TEXT,           -- 可空的加密字段
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX idx_vault_email ON vault_records(email_idx);

email_idx 上有索引,WHERE email_idx = ? 走 B 树查找。email_enc 从不参与搜索。

FieldCrypto:加密、解密、盲索引

final readonly class FieldCrypto
{
private const string ALGO = 'aes-256-gcm';
private const int TAG_LEN = 16;
private const int NONCE_LEN = 12;

public function __construct(
private string $encKey,   // 必须是 32 字节
private string $indexKey, // 必须是 32 字节
) {
if (strlen($this->encKey) !== 32) {
throw new \InvalidArgumentException('encKey must be exactly 32 bytes.');
}
}

public function encrypt(string $plaintext): string
{
$nonce = random_bytes(self::NONCE_LEN); // 每个值使用全新 IV
$tag = '';
$ct = openssl_encrypt(
$plaintext, self::ALGO, $this->encKey,
OPENSSL_RAW_DATA, $nonce, $tag, '', self::TAG_LEN,
);
return base64_encode($nonce . $ct . $tag);
}

public function decrypt(string $encoded): string
{
$raw = base64_decode($encoded, strict: true);
$nonce = substr($raw, 0, self::NONCE_LEN);
$tag = substr($raw, -self::TAG_LEN);
$ct = substr($raw, self::NONCE_LEN, strlen($raw) - self::NONCE_LEN - self::TAG_LEN);
$pt = openssl_decrypt($ct, self::ALGO, $this->encKey, OPENSSL_RAW_DATA, $nonce, $tag);
if ($pt === false) {
throw new \RuntimeException('Decryption failed — tag mismatch or corrupt ciphertext.');
}
return $pt;
}

/** 确定性——相同输入始终产生相同输出。允许 WHERE email_idx = ? 查询而无需解密存储的密文。 */
public function blindIndex(string $plaintext): string
{
return hash_hmac('sha256', $plaintext, $this->indexKey);
}
}

三段各司其职:encrypt() 负责拼 nonce ‖ ct ‖ tag,decrypt() 按固定长度从尾部切 tag、从头部切 nonce,blindIndex() 用独立的 indexKey 做 HMAC-SHA256。索引密钥和加密密钥必须是两个不同的 32 字节值,否则 HMAC 域和 AES 域混在一起,泄露一个等于泄露另一个。

写入加密,读取解密

创建记录时,敏感字段全部在 INSERT 前加密,同时算出索引:

public function create(int $userId, string $name, string $email, ?string $notes): VaultRecord
{
$stmt->execute([
'name_enc'  => $this->crypto->encrypt($name),
'email_enc' => $this->crypto->encrypt($email),
'email_idx' => $this->crypto->blindIndex($email), // 确定性,用于搜索
'notes_enc' => $notes !== null ? $this->crypto->encrypt($notes) : null,
]);
}

读取在 hydration 里透明完成,上层拿到的永远是明文对象:

private function hydrateRow(array $row): VaultRecord
{
return new VaultRecord(
name:  $this->crypto->decrypt((string) $row['name_enc']),
email: $this->crypto->decrypt((string) $row['email_enc']),
notes: $row['notes_enc'] !== null ? $this->crypto->decrypt((string) $row['notes_enc']) : null,
);
}

盲索引搜索与更新同步

查询时用明文再算一次索引,和存储的索引比对:

public function findByEmail(int $userId, string $email): array
{
$idx = $this->crypto->blindIndex($email); // 相同密钥 → 相同索引
$stmt = $this->pdo->prepare(
'SELECT * FROM vault_records WHERE user_id = :user_id AND email_idx = :idx',
);
$stmt->execute(['user_id' => $userId, 'idx' => $idx]);
// 行随后在 hydrateRow() 中解密
}

注意 user_id 条件和索引条件同时存在,跨用户读取会自然落到 0 行。

更新邮箱是这类实现最容易漏的一步——密文和索引必须一起改:

$stmt->execute([
'email_enc' => $this->crypto->encrypt($newEmail),
'email_idx' => $this->crypto->blindIndex($newEmail), // ← 必须同时更新
]);

只更新 email_enc 的后果是:新邮箱登录查不到,旧邮箱还能查到人,但解出来是新值。这种错位在生产里排查成本很高,测试里专门有一条「盲索引重建(邮箱更新时保持索引同步)」盯着它。

响应隔离与篡改检测

密文列不进 API 响应,由 VO 的 toArray() 层兜住:

public function toArray(): array
{
return ['id' => $this->id, 'name' => $this->name, 'email' => $this->email, 'notes' => $this->notes, 'created_at' => ..., 'updated_at' => ...];
// name_enc、email_enc、email_idx、notes_enc——从不暴露
}

解密失败抛 RuntimeException,交给全局错误处理器返回 500:

if ($pt === false) {
// 标签不匹配 = DB 行被篡改 OR 密钥错误
// 抛出异常——让全局错误处理器返回 500
// 不要返回 400——400 是客户端错误;这是内部完整性故障
throw new \RuntimeException('Decryption failed.');
}

返回 400 会误导调用方以为自己传错了数据。500 才对应「服务端完整性出问题」,而且不告诉对方是哪个字段、因为什么失败。

密钥分离与来源

// 生产环境:从 KMS 或密钥管理器派生密钥
$encKey = random_bytes(32);   // 32 字节 = AES-256
$indexKey = random_bytes(32); // 独立密钥——不同的 HMAC 域
// 绝不在源码中硬编码密钥;使用环境变量或密钥派生:
$encKey = hex2bin(getenv('VAULT_ENC_KEY'));    // 64 位十六进制 → 32 字节
$indexKey = hex2bin(getenv('VAULT_INDEX_KEY')); // 64 位十六进制 → 32 字节

两个密钥的轮换成本不一样。encKey 可以逐行重新加密;indexKey 一旦更换,所有存量索引都得重算,等于全表重写。所以索引密钥泄露的处置预案必须提前写,不能等出事再想。

哪些列压根不该加密

字段级加密的适用范围比「所有敏感列」窄得多。我的判断是:先把不该加密的列排除掉,剩下要加密的列才谈得上设计盲索引。

低基数列不要加密——性别、状态、类型这类只有几个取值的字段,加密后既拿不到统计,也做不了 WHERE status = 'active',盲索引的取值空间太小,HMAC 输出等于把明文枚举映射了一遍,防不住字典攻击。这类列靠访问控制解决。

password_hash 不要加密。它本身就是单向哈希,再套一层 AES 只会让验证流程多一次解密,安全性没有增量。

金额和时间列不要加密。这两类字段的典型用法是范围查询、排序和聚合:WHERE amount > ?、ORDER BY created_at DESC、按月份 GROUP BY。随机 nonce 下密文没有顺序,这些操作在密文上全部失效,盲索引也只支持精确匹配。真要对金额做保密的范围查询,那是另一套方案(保序加密或应用层过滤),不在字段级加密的射程里。

同理,LIKE '%@gmail.com' 这类模糊匹配在盲索引上也做不了——FT267 的 V-05 明确写了只支持精确匹配,不支持通配符和子串。需要模糊搜索的字段要么另建派生列,要么把搜索挪到专门的检索系统。

测试与 VULN 评估

FT187 的测试结果:51 个测试 / 110 个断言全部通过;PHPStan level 8 无错误;PHP CS Fixer 干净。覆盖范围包括 FieldCrypto 单元的加解密往返、nonce 唯一性、盲索引确定性、篡改检测、短密钥拒绝,以及密文隔离(name_enc、email_enc、email_idx、notes_enc 不出现在响应中)、IDOR 防护(跨用户的获取/更新/删除返回 404)、批量赋值(请求体里的 name_enc、email_idx、user_id 被忽略)、校验和盲索引重建。

FT267 的 VULN 评估给出 6 SAFE、4 EXPOSED。四项暴露都不是 AES-256-GCM 或盲索引的密码学设计问题,而是生产上线前的集成缺口:

V-04 解密错误详情泄露(EXPOSED) — 错误处理器把 RuntimeException::getMessage() 序列化进响应。APP_DEBUG=true 时可能带出完整消息和堆栈;即使关掉,默认处理器仍可能暴露异常类名。修法是专门写一个 handler,映射到 500 加通用的 internal-error Problem Details 响应体,标签失败只记服务端日志。

V-06 端点无认证授权(EXPOSED) — /vault/{userId}/records 没有 API key、JWT 或 session 检查,user_id 由调用者自己传。这条在示例里是有意限定范围,但真上生产必须改成从令牌派生 $userId,绝不信任请求体里的值。

V-08 密钥轮换缺口(EXPOSED) — 换掉 VAULT_ENC_KEY 之后旧密文全部解不开,没有版本控制、没有重加密工具、没有迁移文档。修法是每个 blob 加版本前缀(v1:),解密时按版本选密钥,并提供事务内的解密加重新加密脚本。

V-10 明文残留在内存和日志(EXPOSED) — 解密后的明文会出现在 PHP 异常堆栈、请求日志中间件、错误输出和 APM 追踪里。请求体日志中间件在加密前记录 POST body,等于把明文字段原样落盘。需要做三件事:从请求体日志排除 /vault 载荷;在 VaultRecord 上实现 __debugInfo() 编辑敏感字段;错误追踪(Sentry 之类)传输前清洗。

这四项里,V-08 和 V-10 是最容易在「功能测试全绿」之后被忘掉的——它们不影响加解密正确性,只影响出事之后能不能收场。

推荐文章

程序员茄子在线接单