把数据库中的敏感字段“加密一下”,听起来只是调用一次Cipher.doFinal。真正容易出错的却是算法模式、随机数、同一密钥下 nonce 重复、密文格式以及密钥存放位置。加密解决的是特定威胁模型里的机密性与完整性,不是把字符串 Base64 一下,更不能代替访问控制和审计。
一、先明确要保护什么
传输中的敏感信息通常首先依靠 TLS;应用层字段加密针对的是备份、某些存储层或特定人员访问数据库时的暴露。若应用和密钥都在同一台机器、同一权限域,攻击者拿到整个运行环境时,加密效果可能有限。要列出攻击者能访问什么、解密由谁执行、查询是否需要明文、删除和轮换怎样实施,再选择方案。
密码的保存与可逆加密不同:登录密码应采用专门的加盐、抗暴力破解密码哈希方案,而不是 AES 加密后存库。需要按密文字段查询或去重时,也不能直接假设随机化加密后的字节可做等值比较;设计上可能需要额外的索引策略与泄漏风险分析。
二、为什么选择带认证的加密模式
AES/GCM/NoPadding同时提供加密与认证,解密时能发现密文或关联数据被改动。只用AES/ECB/PKCS5Padding会泄露重复数据的模式;只加密不校验完整性,则可能让篡改后的数据进入业务逻辑。GCM 中 nonce 必须在同一密钥下保持唯一。常见做法是每次生成新的 12 字节随机 nonce,并与密文一起存储。随机并不意味着在无限次数使用下绝无碰撞,超大规模部署还应制定使用上限和轮换策略。
GCMParameterSpec(128, nonce)里的 128 是认证标签的位数,不是 AES 密钥长度。AES 密钥可用KeyGenerator生成;nonce 不需要保密,但绝不能为了方便而使用固定全零数组。示例每次启动生成临时密钥,因此只展示一次运行内的往返,进程退出后无法再解密此前生成的数据。
三、完整可运行的加解密示例
保存为GcmDemo.java,运行javac GcmDemo.java和java GcmDemo。输出经 Base64 编码,格式是nonce || ciphertext-and-tag。Base64 是编码而不是加密。
importjava.nio.ByteBuffer;importjava.nio.charset.StandardCharsets;importjava.security.SecureRandom;importjava.util.Base64;importjavax.crypto.Cipher;importjavax.crypto.KeyGenerator;importjavax.crypto.SecretKey;importjavax.crypto.spec.GCMParameterSpec;publicclassGcmDemo{privatestaticfinalSecureRandomRANDOM=newSecureRandom();privatestaticfinalintNONCE_BYTES=12;privatestaticfinalintTAG_BITS=128;staticStringencrypt(SecretKeykey,Stringvalue,byte[]aad)throwsException{byte[]nonce=newbyte[NONCE_BYTES];RANDOM.nextBytes(nonce);Ciphercipher=Cipher.getInstance("AES/GCM/NoPadding");cipher.init(Cipher.ENCRYPT_MODE,key,newGCMParameterSpec(TAG_BITS,nonce));cipher.updateAAD(aad);byte[]encrypted=cipher.doFinal(value.getBytes(StandardCharsets.UTF_8));returnBase64.getEncoder().encodeToString(ByteBuffer.allocate(nonce.length+encrypted.length).put(nonce).put(encrypted).array());}staticStringdecrypt(SecretKeykey,Stringencoded,byte[]aad)throwsException{byte[]combined=Base64.getDecoder().decode(encoded);if(combined.length<NONCE_BYTES+TAG_BITS/8)thrownewIllegalArgumentException("invalid ciphertext");ByteBufferbuffer=ByteBuffer.wrap(combined);byte[]nonce=newbyte[NONCE_BYTES];buffer.get(nonce);byte[]encrypted=newbyte[buffer.remaining()];buffer.get(encrypted);Ciphercipher=Cipher.getInstance("AES/GCM/NoPadding");cipher.init(Cipher.DECRYPT_MODE,key,newGCMParameterSpec(TAG_BITS,nonce));cipher.updateAAD(aad);returnnewString(cipher.doFinal(encrypted),StandardCharsets.UTF_8);}publicstaticvoidmain(String[]args)throwsException{KeyGeneratorgenerator=KeyGenerator.getInstance("AES");generator.init(256);SecretKeykey=generator.generateKey();byte[]aad="customer:42".getBytes(StandardCharsets.UTF_8);Stringsaved=encrypt(key,"example secret",aad);System.out.println(decrypt(key,saved,aad));}}关联数据 AAD 不加密,却受认证保护。示例把记录 ID 作为 AAD,目的是防止把某条记录的密文简单搬到另一条记录后仍被接受。加密与解密两侧必须使用完全相同的字节,改动 ID 后解密会失败。异常不要降级为“返回空字符串”;这会把数据损坏或攻击变成貌似正常的业务值。
四、密文格式要能演进
真实存储格式至少应包含版本、密钥标识、nonce 和密文及标签,并严格校验长度和字段。示例省略版本与密钥 ID,只适合教学。轮换时,新写入使用新密钥,旧数据按旧密钥 ID 解密;可在读取时重加密,也可计划后台迁移。直接换掉旧密钥且没有迁移,历史数据就无法解密。
不要把原始密钥写进源码、环境日志、数据库同一字段或客户端安装包。密钥应由受权限控制的密钥管理服务或硬件支持系统提供,并定义调用权限、审计、备份与灾难恢复。KMS 保存主密钥、数据密钥按记录或批次包裹,是常见方向;具体架构必须依据吞吐、成本、合规和故障模式设计。
五、随机数与 nonce 的生命周期
SecureRandom用于生成安全敏感随机值,Random不适合这里。一个长期使用的 AES 密钥若执行大量加密操作,随机 nonce 的碰撞风险会随操作次数增加,必须对密钥作用域、最大调用量和轮换频率做工程约束。也可用严格管理的计数式 nonce,但跨实例分配、重启恢复和防回退更难。最危险的是程序重启后重用同一个固定值,或多实例共享密钥却各自从零计数。
Cipher实例含内部状态,不要当成全局单例被多个线程并发使用;每次操作创建或采用经过审查的池化方案。调用updateAAD必须在doFinal前、处理正文前完成。解密失败时不要告诉外部用户“密钥错误还是标签错误”,统一暴露可操作的内部错误并避免泄露敏感细节。
六、测试与上线清单
测试往返正确性之外,还要篡改 nonce、密文、标签和 AAD,确保失败;测试空字符串、中文、长文本和无效 Base64。每次加密相同明文应产生不同结果,同时都能解密。检查日志、异常、监控标签是否意外记录明文和密钥。备份恢复演练必须同时包括密文与可用密钥;只有数据库备份而没有密钥恢复方案,等于无法恢复数据。
本文的底线是:正确算法仅是第一层,密钥权限、nonce 唯一性、格式演进和运维恢复同样属于密码学系统。安全方案应由熟悉威胁模型的团队评审,不能把一段能运行的演示代码直接贴进生产服务。
七、密钥从哪里来,谁能使用
示例中的KeyGenerator每次启动生成新密钥,展示了正确的随机密钥生成方式,却无法满足长期存储的解密要求。生产系统需要有受控密钥来源:主密钥由密钥管理服务管理,应用在授权范围内取得或调用解密能力,数据密钥可被主密钥保护。只有取得数据密钥权限的服务能够解密相应字段;普通数据库只持有密文和元数据。
权限应按服务身份与业务环境划分。开发、测试、生产不能共享同一把密钥;后台报表任务未必需要解密全部用户信息。记录密钥访问审计,定期检查权限和异常调用量。不要把密钥放入异常堆栈、环境变量转储、配置仓库或 CI 构建日志。依赖环境变量传递密钥时也要认识到进程诊断、容器配置和部署系统的读取权限风险。
密钥轮换并非改一条配置:旧密文仍需旧密钥解密。一个可操作流程是格式中存密钥版本,新数据使用新版本,读旧数据时按版本选择旧密钥,并以受控速率重新加密。完成迁移、备份过期和恢复演练后,才能考虑禁用旧密钥。必须提前定义旧密钥被怀疑泄露时的应急流程,而不是等事故发生才发现无法批量迁移。
八、认证失败与错误处理
GCM 解密期间,密文、nonce、AAD 或密钥任何一项不一致,都可能表现为认证失败。对外接口通常应给统一失败响应,内部则记录足够的上下文,例如记录 ID、密钥版本、操作阶段和受控错误码,但不记录明文、原始密钥或完整密文。不能在失败时试图“忽略标签继续解密”,那会放弃模式最重要的完整性保证。
解密前先校验 Base64 长度和版本字段,给输入总长度设置上限,以防止超大恶意数据造成内存问题。认证通过之后才能使用明文;不要在流式处理过程中提前把未经认证的片段提交到下游。若数据经压缩后再加密,读取时还需限制解压后的大小,并检查业务数据格式。
九、加密不是访问控制的替代品
加密可减少部分静态数据泄露,但合法应用仍会解密数据,具有应用权限的攻击者可能通过正常接口取得明文。应将 API 授权、最小权限、日志脱敏、审计与速率限制一同设计。尤其不能因为数据库字段是密文,就允许任意服务访问解密接口;否则解密服务只是另一个不受控的明文数据库。
业务字段如果要做前缀查询、排序或模糊搜索,普通随机化加密无法直接满足。为搜索单独创建索引会带来可观察信息泄露,必须分析攻击者可见的查询模式与频率,不能简单把密文拿去建索引。不同字段也不应因“实现方便”随意复用同一密钥和 nonce 规划;隔离作用域可以限制单点失误的影响。
十、恢复演练比文档更可靠
把数据备份拷贝到隔离恢复环境,验证密钥服务、权限、版本元数据和应用代码能够一起恢复。只验证加密成功而不验证历史数据轮换与恢复,系统可能在真正需要备份时才发现密钥已停用。测试还应覆盖密钥服务短暂不可用:是快速失败、排队等待,还是允许只读降级,都应由业务确定。加密系统的可用性与安全性必须同时纳入设计。
参考资料:Cipher API、GCMParameterSpec API、SecureRandom API。