Node.js解密Rails Active Record AES-256-GCM加密数据失败
AES-256-GCM解密认证失败排查与修复方案
该问题是Rails Active Record Encryption默认实现细节与Node.js crypto模块默认约定不对齐导致,按以下优先级排查即可解决:
1. 错误解析数据库存储的加密Payload(90%场景的根因)
Rails不会将密文、IV、认证标签拆分存储,数据库字段中保存的加密值是两层包装结构:
- 外层为URL安全Base64编码,解码后得到JSON格式的元数据+密文串
- JSON结构固定包含两个字段:
p:Base64编码的原始AES-GCM密文h:元数据对象,内嵌两个Base64编码字段:iv:12字节GCM初始向量at:16字节GCM认证标签
不要直接将数据库读出的整串值作为密文传入,也不要手动从密文尾部截断16字节作为认证标签,必须按结构解析:
// 从数据库读取的原始加密字符串,不要做trim、转义等额外处理 const storedVal = "数据库取出的加密字段值"; // 处理Rails用的URL安全Base64:替换特殊字符、补全padding const base64Val = storedVal.replace(/-/g, '+').replace(/_/g, '/').padEnd( storedVal.length + (4 - storedVal.length % 4) % 4, '=' ); const payload = JSON.parse(Buffer.from(base64Val, 'base64').toString('utf8')); const ciphertext = Buffer.from(payload.p, 'base64'); const iv = Buffer.from(payload.h.iv, 'base64'); const authTag = Buffer.from(payload.h.at, 'base64');
2. 密钥编码/派生逻辑不匹配
Rails配置中的primary_key是Base64编码的32字节原始密钥,不要使用hex编码转Buffer,转码后必须校验长度为32字节(AES-256密钥固定长度要求)。
默认配置下Rails不会直接使用primary_key作为加密密钥,会通过HKDF-SHA256基于salt做密钥派生,Node.js必须使用完全一致的派生参数:
const crypto = require('crypto'); // 从Rails配置中取出的primary_key和key_derivation_salt const railsPrimaryKey = "Rails配置里的primary_key"; const railsSalt = "Rails配置里的key_derivation_salt"; // 与Rails源码完全一致的HKDF参数 const derivedKey = Buffer.from(crypto.hkdfSync( 'sha256', Buffer.from(railsPrimaryKey, 'base64'), Buffer.from(railsSalt, 'base64'), 'ActiveRecord::Encryption', // Rails源码硬编码的info字段,不能修改 32 ));
3. 遗漏GCM附加认证数据(AAD)
如果加密时开启了确定性加密(模型中配置encrypts :字段名, deterministic: true),Rails会将模型名.字段名的ASCII编码串作为AAD参与GCM校验,Node.js解密时必须传入完全一致的AAD,否则必然触发认证失败。非确定性加密默认AAD为空字符串,无需额外设置。
正确的解密逻辑示例:
const decipher = crypto.createDecipheriv('aes-256-gcm', derivedKey, iv); // 确定性加密必须加这行,字符串与Rails侧的"模型名.字段名"完全一致 decipher.setAAD(Buffer.from('User.phone', 'ascii')); // setAuthTag必须在update调用前、final调用前执行 decipher.setAuthTag(authTag); let decrypted = decipher.update(ciphertext, null, 'utf8'); decrypted += decipher.final('utf8'); // Rails默认对明文开启zlib压缩,解密后如果是乱码,执行解压即可 const zlib = require('zlib'); const finalPlaintext = zlib.inflateSync(Buffer.from(decrypted, 'binary')).toString('utf8');
其他校验点
- 确认IV长度为12字节、认证标签长度为16字节,长度不符合说明Payload解析步骤错误
- 不要对数据库读出的加密值做任何截断、转义、trim处理,单个字符缺失就会导致GCM校验失败
- 若需要兼容Rails 7.0之前的非内置加密格式(如attr_encrypted、lockbox),才需要按「前12字节为IV、最后16字节为认证标签、中间为密文」的规则拆分Payload
内容的提问来源于stack exchange,提问作者Steve Johnstone
相关产品推荐
相关产品推荐

