做一个前后端都对不上号的加密需求很少是因为算法本身不会用多数时候是两边对同一个概念的默认值理解不一样。我在某个项目里对接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而不是DESDES算法诞生于上世纪密钥长度只有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-12824字节对应AES-19232字节对应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位字符串给CryptoJSJava端如果把同一字符串的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第一个参数是认证标签长度单位是bit128是常见选择。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统一转成字符串然后按“key1value1key2value2”拼接再把时间戳、随机数和共享密钥一起拼进去使用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 timestamp 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-8Base64密文里出现换行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、字符集这三样大概率能解决问题。希望这篇实战笔记能帮你少踩几个坑。