做一个前后端都对不上号的加密需求,很少是因为算法本身不会用,多数时候是两边对同一个概念的默认值理解不一样。我在某个项目里对接AES加解密时就遇到过,前端生成的一串密文,后端怎么都解不开,最后发现只是密钥的编码方式不同。这篇文章想把Java和JavaScript里常用的编码、哈希、对称加密、非对称加密以及接口签名这些实战细节完整串一遍,适合正在做前后端分离、需要传输敏感数据的开发者参考。
加密和编码是两个容易被混为一谈的话题,但工程里的每一步都和它们有关。我会尽量把代码写完整,把参数说明白,把坑点讲清楚,毕竟这些细节我也是踩过不少坑才总结出来的。
1. 编码与哈希:先把地基打牢
1.1 Base64不是加密,但不能不懂
Base64虽然看起来像密文,但本质上是一种编码规则。它把二进制数据映射到64个可打印字符上,方便在文本协议里传输。加密和签名操作输出的是字节数组,而JSON、数据库字段通常只接受文本,所以需要用Base64把字节“包装”成字符串。前后端解密对不上的时候,问题往往不出在算法,而是出在这一层。
Java侧使用起来很简单:
import java.util.Base64; byte[] raw = "hello".getBytes(StandardCharsets.UTF_8); String encoded = Base64.getEncoder().encodeToString(raw); byte[] restored = Base64.getDecoder().decode(encoded);Java的Base64有几种模式,区别值得记住。基本模式不做URL处理,MIME模式会在每76个字符后插入换行,URL安全模式会用“-”和“_”代替“+”和“/”。如果你从某个服务端拿到一段密文,放到URL参数里发现“+”变成了空格,多半是没切换到URL安全的Base64,或者拿到的密文本身就混入了MIME换行。
JavaScript侧直接使用btoa和atob会遇到一个经典问题:它们只支持Latin1字符。如果对中文JSON直接btoa,浏览器会抛异常,因为中文字符的码点超出了Latin1范围。我之前做上报接口时踩过一次,控制台全是红色报错,排查半天才发现是这个问题。正确做法是先转成UTF-8字节再编码:
function b64EncodeUtf8(str) { return btoa(unescape(encodeURIComponent(str))); } function b64DecodeUtf8(str) { return decodeURIComponent(escape(atob(str))); }这段代码先用encodeURIComponent把字符串转成UTF-8百分号编码,再通过unescape还原成字节序列,最后交给btoa。工程里非常实用。如果项目环境允许的话,也可以直接用TextEncoder生成Uint8Array,再手动循环转换字节到Base64字符,这种方式更符合现代API习惯,也更好维护。
1.2 十六进制与URL编码:看得见的编码工具
Base64能压缩输出体积,但有时候我们需要更直观的展示和比对,比如日志、调试、文件校验,这时候用十六进制更合适。一个字节对应两个十六进制字符,内容肉眼可读,复制粘贴也不会出现特殊字符问题。Java 17之后可以直接用HexFormat,老版本也可以手动拼接。JavaScript里的做法是遍历Uint8Array,对每个字节调用toString(16),不满两位补零。
URL编码负责另一件事:把非安全字符变成百分号格式,确保参数能在URL里安全传递。Java的URLEncoder.encode和JavaScript的encodeURIComponent有个细节差异,Java实现的默认行为会把空格变成“+”,而JavaScript会变成“%20”。如果两边对同一串字符编码,结果在某些情况下会不一样。为了避免这种差异,我给前端签名或者加密参数时都会定一个规矩:所有需要放进URL或请求体的加密串,统一用URL安全的Base64,然后整体再做一次encodeURIComponent。这样能绕过“+”和空格的坑,也方便服务端解析。
1.3 哈希与消息认证码:数据的指纹
哈希算法是单向的:输入任意长度的数据,输出固定长度的摘要。常见的MD5、SHA-1、SHA-256都属于这一类。它们不是用来加密的,因为结果不可逆,主要用于完整性校验、密码验证和接口签名。MD5虽然很快,但已经被证明存在碰撞风险,我不建议在新项目里继续使用,至少在涉及安全的场景里换成SHA-256。
Java计算SHA-256摘要:
MessageDigest md = MessageDigest.getInstance("SHA-256"); byte[] digest = md.digest("hello".getBytes(StandardCharsets.UTF_8)); System.out.println(HexFormat.of().formatHex(digest));JavaScript在支持Web Crypto的现代浏览器里可以这么写:
const data = new TextEncoder().encode('hello'); const digest = await crypto.subtle.digest('SHA-256', data);需要注意的是,crypto.subtle要求在安全上下文中使用。开发时如果通过http访问本地页面或内网IP,控制台会提示不支持,要么把域名配置成localhost,要么加一层HTTPS。我在本地联调时经常被这个拦截,后来习惯先把网页跑在localhost端口上再调试。
纯哈希最大的弱点是无法区分数据来源,任何人都能算同一个摘要。所以真正做签名或者校验时会用HMAC:带密钥的哈希。HMAC要求调用方持有同一个密钥,没有密钥就算不出合法签名。Java用Mac类实现,JavaScript可以用成熟的前端加密库或者Web Crypto。这是后面接口签名方案的基础。
2. 对称加密:AES才是实战主力
2.1 为什么是AES而不是DES
DES算法诞生于上世纪,密钥长度只有56位,在现有算力下暴力破解已经不是难事。3DES虽然加长了有效密钥,但处理速度慢,新项目基本不会再用。AES是公开的分组密码标准,支持128/192/256位密钥,性能和安全性都经过了长期检验,各语言实现也都很成熟。只要不是和旧系统做兼容,我都会直接选AES。
AES本身按固定大小的块加密,每个块16字节。明文长度如果恰好是16的倍数,可以直接加密;不是的话就需要填充。密文长度因此可能比明文多若干字节。PKCS5和PKCS7在实践里经常被当作同义词使用,Java的PKCS5Padding实际行为更接近PKCS7。很多在线工具和本地库之间解密失败,有一部分就是因为两边对填充的处理方式不一致。
2.2 模式与填充:三个最让人头大的参数
对称加密里有三个高频配置:工作模式、填充方式、初始向量。它们任何一个不一致,跨语言解密就会失败。
ECB模式最简单,每个明文块独立加密,同样的明文块会得到同样的密文块,容易被分析出规律,不推荐用于业务数据。CBC模式中,每个明文块先和上一块密文异或,再执行加密,第一个块依赖IV。因为结果链式传递,所以同样的明文在不同位置、不同IV下会产生不同结果,安全性比ECB好很多。GCM模式是认证加密模式,不需要填充,同时生成认证标签,密文一旦被篡改,解密端能立刻发现。
IV是CBC模式必须的参数,长度和块大小一致,通常是16字节。IV不需要保密,但建议每次加密都随机生成。如果IV固定,相同明文会产生相同密文,长时间观察下会泄露模式信息。有些代码为了省事把IV写死,这种用法在演示里没问题,放到正规项目里就不够了。更合理的做法是每次加密生成随机IV,并把IV拼在密文前面一起交给接收方。
2.3 Java端AES加解密落地
Java的Cipher使用起来直接,但有几个细节我会特意注意。密钥长度决定算法规格:16字节密钥对应AES-128,24字节对应AES-192,32字节对应AES-256。旧版JDK对256位支持有限制,需要额外配置政策文件,不过现在主流JDK已经默认放开。另一个细节是字符集,明文转字节必须统一UTF-8,否则解密出来的中文字符就是乱码。
下面是Java侧一个CBC模式的完整工具方法:
import javax.crypto.Cipher; import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; import java.util.Base64; public class AesUtil { private static final String KEY_ALGORITHM = "AES"; private static final String CIPHER_ALGORITHM = "AES/CBC/PKCS5Padding"; public static String encrypt(String plainText, String base64Key, String base64Iv) { try { byte[] keyBytes = Base64.getDecoder().decode(base64Key); byte[] ivBytes = Base64.getDecoder().decode(base64Iv); Cipher cipher = Cipher.getInstance(CIPHER_ALGORITHM); cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(keyBytes, KEY_ALGORITHM), new IvParameterSpec(ivBytes)); byte[] encrypted = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(encrypted); } catch (Exception e) { throw new RuntimeException("AES加密失败", e); } } public static String decrypt(String cipherText, String base64Key, String base64Iv) { try { byte[] keyBytes = Base64.getDecoder().decode(base64Key); byte[] ivBytes = Base64.getDecoder().decode(base64Iv); Cipher cipher = Cipher.getInstance(CIPHER_ALGORITHM); cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(keyBytes, KEY_ALGORITHM), new IvParameterSpec(ivBytes)); byte[] decrypted = cipher.doFinal(Base64.getDecoder().decode(cipherText)); return new String(decrypted, StandardCharsets.UTF_8); } catch (Exception e) { throw new RuntimeException("AES解密失败", e); } } }这里我故意把key和IV都用Base64字符串传入,好处是前后端约定简单,不会因为文本编码出问题。Base64解码后得到的字节数决定了实际密钥长度,如果约定用32字节的key,生成密钥时就要确保Base64字符串解码后长度是32字节。
2.4 JS端AES与Java互通
前端最常用的是CryptoJS。下面这段代码可以和上面的Java代码互通,只要密钥、IV、模式和填充都保持一致:
const CryptoJS = require('crypto-js'); function aesEncrypt(plainText, base64Key, base64Iv) { const key = CryptoJS.enc.Base64.parse(base64Key); const iv = CryptoJS.enc.Base64.parse(base64Iv); const encrypted = CryptoJS.AES.encrypt( CryptoJS.enc.Utf8.parse(plainText), key, { iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 } ); return encrypted.toString(); }这里有一个高频坑必须重视:CryptoJS.AES.encrypt如果直接传一个普通字符串当作key,库内部会按照OpenSSL的规则对字符串做一次密钥派生,而不是直接使用字符串的UTF-8字节。也就是说,你传“abcdefghijklmnop”这样一个16位字符串给CryptoJS,Java端如果把同一字符串的UTF-8字节当作密钥去解密,两边会完全对不上。解决方式很明确:手动用CryptoJS.enc.Utf8.parse或CryptoJS.enc.Base64.parse把key字符串转成WordArray,再传入加密方法。
我经常遇到前端同事反馈“后端解不开”,排查到最后往往是这类细节。跨端联调时,先拿同一份明文、同一个key、同一个IV,分别用两边代码加密,对比Base64密文是否一致。如果不一致,逐层检查key字节、IV字节、填充方式、输出编码。别跳过字符集,中文字符在UTF-8下是多个字节,非常容易成为分水岭。
2.5 GCM:带认证的加密
从安全性角度,我更推荐GCM。CBC模式被篡改后,解密出来往往是一堆乱码,有时候很难第一时间发现问题;GCM自带认证标签,接收方能验证密文完整性,任何位翻转都会导致解密直接失败。
Java里GCM的用法如下:
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); SecretKeySpec keySpec = new SecretKeySpec(keyBytes, "AES"); GCMParameterSpec gcmSpec = new GCMParameterSpec(128, ivBytes); cipher.init(Cipher.ENCRYPT_MODE, keySpec, gcmSpec); byte[] encrypted = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8));GCMParameterSpec第一个参数是认证标签长度,单位是bit,128是常见选择。GCM没有填充,所以密文和明文长度基本一致,但要额外存储IV和认证标签。实战中通常把IV放在密文前面,或者按固定长度切成iv + cipher + tag再整体Base64,接收方知道布局就能正确解析。
如果必须在浏览器做GCM,可以直接用Web Crypto的AES-GCM。相比在CryptoJS基础上做扩展,Web Crypto更标准,也更容易和后端对齐。需要注意的是,Web Crypto返回的ArrayBuffer和Base64之间需要自己写转换函数,这部分不算复杂,但比CryptoJS多几行胶水代码。
3. 非对称加密:RSA的前后端协作
3.1 密钥格式与编码:Java和JS读不懂对方的公钥
RSA最常见的用法有两类:公钥加密、私钥解密,保护敏感数据;私钥签名、公钥验签,确认数据来源。前后端协作时,密钥格式问题比算法本身更容易让人崩溃。
Java生成的公钥是X.509格式,私钥是PKCS#8格式;很多前端工具默认使用的是PEM字符串或PKCS#1格式。如果你从某个在线工具导出“BEGIN RSA PRIVATE KEY”开头的私钥,直接拿去Java解析,大概率会报“Unknown key spec type”。解决办法不是硬解析,而是统一格式:要么使用Java的PKCS#8标准,要么在生成密钥对时直接转成PEM字符串。我个人习惯是在后端统一管理密钥对,公钥以X.509的Base64形式通过接口下发给前端,前端直接使用成熟库解析。如果公钥缺少“BEGIN PUBLIC KEY”头,就手动拼接后再交给解析器,大多数库都能处理这种字符串。
3.2 公钥加密私钥解密
前端用公钥加密,后端用私钥解密,是保护登录密码、身份证号等敏感字段的常用方案。相比直接把明文放到请求体里,这样至少能避免日志记录时明文暴露。
Java侧私钥解密的典型写法:
import java.security.KeyFactory; import java.security.PrivateKey; import java.security.spec.PKCS8EncodedKeySpec; import javax.crypto.Cipher; import java.util.Base64; public static String rsaDecrypt(String cipherText, String base64PrivateKey) { byte[] keyBytes = Base64.getDecoder().decode(base64PrivateKey); PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes); KeyFactory factory = KeyFactory.getInstance("RSA"); PrivateKey privateKey = factory.generatePrivate(spec); Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding"); cipher.init(Cipher.DECRYPT_MODE, privateKey); byte[] decrypted = cipher.doFinal(Base64.getDecoder().decode(cipherText)); return new String(decrypted, StandardCharsets.UTF_8); }RSA算法对单次加密内容长度有硬性限制。1024位密钥一次最多能加密117字节,2048位密钥一次约245字节。超长内容不能直接加密,需要分段处理或者使用混合加密。我一般只拿RSA去加密一个随机生成的AES密钥,再用AES加密真正的业务数据。这样既绕开RSA长度限制,又利用对称加密的高性能,这也是TLS等协议里常见的思路。
前端加密时可以用rsa公钥,内部封装步骤很简单:生成一个随机AES密钥,用AES加密业务数据;用RSA公钥加密AES密钥;把密文和加密后的AES密钥一起传给后端。后端先RSA解密得到AES密钥,再用AES解密正文。这个流程逻辑清晰,也避免了对每段数据都做RSA加密导致的性能问题。
3.3 私钥签名公钥验签:防止数据被篡改
RSA签名和RSA加密容易混淆。加密是为了保密,签名是为了防篡改。私钥签名,公钥验签,因为私钥只有服务端持有,所以合法签名只能来自服务端。第三方拿到公钥后能验证,但无法伪造签名。
Java端签名:
import java.security.Signature; Signature signature = Signature.getInstance("SHA256withRSA"); signature.initSign(privateKey); signature.update(data.getBytes(StandardCharsets.UTF_8)); byte[] sigBytes = signature.sign();前端验签时使用同一把公钥。如果项目只服务自己的多个端,可以用HMAC共享密钥方案;如果要开放给大量外部系统,RSA签名更合适,因为公钥可以公开分发,无需每次合作都交换同一个保密密钥。
签名算法名称也值得注意,Java叫“SHA256withRSA”,前端库常写成“sha256WithRSAEncryption”,两者底层是同一个算法,但初看容易误会成不同方案。另外签名前参与计算的内容必须是双方完全约定的字节序列,包括字段顺序、分隔符、字符集,任何差异都会导致验签失败。
4. 一套能用的接口签名方案
4.1 设计思路:参数排序、拼接、签名、时间戳
接口签名不是单纯调一个加密函数,它是一套约定。我常用的方案如下:业务参数取Map形式,key按字典序排序,value统一转成字符串,然后按“key1=value1&key2=value2”拼接;再把时间戳、随机数和共享密钥一起拼进去,使用HMAC-SHA256计算签名值。时间戳用于控制有效期,超过5分钟或10分钟就拒绝,能防止一部分重放攻击;随机数用于让同一时间戳下的请求也不可预测,还能配合服务端缓存做一次去重。
选择HMAC而不是RSA签名,主要看调用方范围。HMAC计算简单,前后端实现成本低,适合自己信任的客户端。如果有大量外部系统接入,RSA签名更好,公钥管理更灵活。签名方法可以独立于业务逻辑,单独抽出一个工具类,这样前后端都容易保持一致。
4.2 Java端实现签名
Java端的签名方法可以写成这样:
import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import java.util.Base64; public static String sign(String sortedParams, String timestamp, String nonce, String secret) { String content = sortedParams + "×tamp=" + timestamp + "&nonce=" + nonce; try { Mac mac = Mac.getInstance("HmacSHA256"); SecretKeySpec keySpec = new SecretKeySpec( secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"); mac.init(keySpec); byte[] hash = mac.doFinal(content.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(hash); } catch (Exception e) { throw new RuntimeException("签名失败", e); } }服务端收到请求后,需要从请求头或表单里取出timestamp、nonce和sign。先检查时间戳与服务器当前时间差是否在合理范围内,再对请求参数做同样的排序和拼接,随后计算签名并比对。nonce需要去重,可以把已使用的nonce放到缓存服务里保存几分钟,过期自动删除,避免同一个请求被重复提交。签名比对不建议直接把两个字符串做equals,最好使用固定时间的比较方式,防止简单时序攻击;对于大多数业务系统,普通字符串比对已经够用,但严谨一点的实现值得保留。
4.3 JS端为了对齐做的几个细节
前端实现同样的签名,最大的坑是对象属性的排序。JavaScript对象key的遍历顺序虽然有规则,但在复杂场景下容易让人误解。所以签名用的参数列表应该显式排序,不要依赖对象遍历。我习惯先取出所有key,调用数组sort方法排序,再拼接。另一个细节是参数值必须转成字符串,尤其当值是对象或数组时,需要统一用JSON.stringify后再参与拼接。Java侧序列化方式和JS侧的JSON.stringify大体一致,但为了安全起见,最好的做法是由服务端定义一个明确的拼接规则文档,把所有边界情况写死。
数字和布尔值也容易出问题。Java的Long类型在某些序列化框架下会变成数字,但JS里所有数字类型都统一按双精度存储,当数字长度超过一定范围时,两边可能不一致。所以参与签名的所有值都建议以字符串形式提供,从源头杜绝类型转换造成的差异。
5. 常见故障排查:这些坑我全都踩过
5.1 一张速查表解决问题
我用一张表总结常见的加密对接故障,遇到问题可以照表排查。
| 现象 | 可能原因 | 排查建议 |
|---|---|---|
| Java解JS密文报BadPaddingException | 密钥不一致、IV不一致、CryptoJS派生密钥 | 固定明文和key,逐层对比中间结果 |
| 解密成功但中文乱码 | 明文转字节用了默认字符集 | 两边统一UTF-8 |
| Base64密文里出现换行 | Java用了MIME编码器 | 改用Base64.getEncoder()或去掉换行 |
| RSA公钥解析失败 | 密钥格式不是X.509/PKCS#8 | 转换格式或前后端统一PEM |
| 密文长度过长解密失败 | RSA一次加密超过长度限制 | 改成混合加密或分段加密 |
| 签名始终对不上 | 参数排序不一致、值没转字符串 | 统一排序规则,JSON.stringify后再拼 |
| URL里“+”变成空格 | 请求参数没有做URL编码 | 使用encodeURIComponent或URL安全的Base64 |
这张表是我多次前后端联调中一点点攒出来的,基本上覆盖了90%的对接问题。真遇到疑难杂症,先别急着怀疑算法实现,按照表格里的顺序逐项排查,往往很快就能定位。
5.2 三个值得反复提醒的细节
第一个是字符集。有一次和同事联调,前端把“用户”两个汉字加密后传给后端,后端解出来只有问号。排查到最后,发现前端在加密前先用系统默认编码做了字节转换,而不是UTF-8。字符集问题很难一眼看出,所以在工具类里最好把UTF-8固定写死,不要依赖运行环境的默认值。
第二个是“在线加解密工具”的误导。很多在线工具默认按OpenSSL格式解析输入,你输入一个密钥字符串,工具会按照特定规则自动做派生,而Java的Cipher不会。同一段数据用在线工具能解,用代码解不了,很可能就是这种派生逻辑的差异。在线工具可以用来验证思路,但最终还是要以代码跨端测试为准。
第三个是测试务必覆盖完整链路。不要只测Java到Java、JS到JS,要专门写一个跨端测试:用Java加密后给JS解,用JS加密后给Java解。每次执行都打印明文、密文、key、IV、签名内容,方便对照。我在项目里习惯准备一个调试接口,把整个加解密过程日志化,联调时省了非常多的时间。
6. 最后再说点实际的
做过多次前后端加密对接后,我的体会是:算法代码本身不难,难的是约定。约定密钥格式、约定编码方式、约定填充模式、约定时间戳字段名,这些全部要写进接口文档,而不是靠两边各自猜。很多时候前端和后端用着同一个名字的“AES”,却因为一个默认参数不同而对不上,这种消耗最不值得。
我建议新项目直接从工具类入手,Java和JS各保留一份公共模块,注释里互相贴代码。验证时先从简单的AES-CBC开始,跑通之后把IV随机化、换成GCM、加上HMAC签名,最后再引入RSA加密密钥。一步一步来,问题都会提前暴露在测试环境里。
如果哪一天你也被“前端加密后端解不开”折磨到怀疑人生,不用急着怀疑算法写错了,先查key编码、IV、字符集这三样,大概率能解决问题。希望这篇实战笔记能帮你少踩几个坑。