通常在项目安全隐私整改时,客户端的登录数据(账号和密码等信息)会被要求加密后传递给服务端,因为通常认为即使在 HTTPS 通道中,明文的口令仍有泄露风险。
某次,在处理 RSA 加解密时遇到了比较奇怪的问题:服务端无法解密客户端的密文。经排查,排除了 RSA 密钥对和密钥组件不一致的情况,即客户端使用正确的 RSA 公钥加密,以及都使用OAEPWithSHA-256AndMGF1Padding方式填充。
解密代码片段
# 以 PKCS#8 格式生成私钥(公钥仍然是 OPENSSH 格式,即 RFC4716/SSH2 格式 PEM 编码)
ssh-keygen -t rsa -m PKCS8 -f priv.key
# 将 OPENSSH 公钥转换为 PKIX 格式 PEM 编码:-----BEGIN PUBLIC KEY-----
ssh-keygen -f priv.key.pub -e -m PKCS8 > pub.pem
// 服务端Java解密代码
public static String decrypt(String data) throws Exception {
byte[] datab = Base64.getDecoder().decode(data);
byte[] prib = Base64.getDecoder().decode(priv); // 私钥 priv 为 PKCS#8 的 PEM 编码串
RSAPrivateKey key =
(RSAPrivateKey) KeyFactory.getInstance("RSA").generatePrivate(new PKCS8EncodedKeySpec(prib));
// ECB 模式没有实际效用(同 NONE),但 JDK 只能这么写
Cipher cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding");
cipher.init(Cipher.DECRYPT_MODE, key);
return new String(cipher.doFinal(datab));
}
// Android端加密代码(存在报错)
@Throws(Exception::class)
fun encrypt(data: String): String? {
val pubBuf: ByteArray = Base64.decode(pub, Base64.DEFAULT)
val key = KeyFactory.getInstance("RSA").generatePublic(X509EncodedKeySpec(pubBuf)) as RSAPublicKey
val cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding")
cipher.init(Cipher.ENCRYPT_MODE, key)
val bytes = cipher.doFinal(data.toByteArray(StandardCharsets.UTF_8))
return Base64.encodeToString(bytes, Base64.DEFAULT)
}
第一步、纠正 Base64 编码差异
上面的客户端代码加密后的密文发送到服务端解密时会报错;报错提示 Base64 字符异常,经过和服务端的加密结果对比可以很清楚地看到,客户端的密文中多了\n字符。这是因为使用android.util.Base64转码时,Base64.Default模式默认会将字符以每 64 位用\n分隔;此处需将客户端 Base64 编码时改用Base64.NO_WRAP模式。
Exception in thread "main" java.lang.IllegalArgumentException: Illegal base64 character a
at java.base/java.util.Base64$Decoder.decode0(Base64.java:743)
at java.base/java.util.Base64$Decoder.decode(Base64.java:535)
at java.base/java.util.Base64$Decoder.decode(Base64.java:558)
at org.example.Main.decrypt(Main.java:74)
at org.example.Main.main(Main.java:61)
// JDK测试加密代码,没有问题
public static String encrypt(String data) throws Exception {
byte[] pubb = Base64.getDecoder().decode(pub);
RSAPublicKey key = (RSAPublicKey) KeyFactory.getInstance("RSA").generatePublic(new X509EncodedKeySpec(pubb));
Cipher cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding");
cipher.init(Cipher.ENCRYPT_MODE, key);
byte[] bytes = cipher.doFinal(data.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(bytes);
}
// 修改Android段代码,改变Base64编码模式(仍然有其他报错)
@Throws(Exception::class)
fun encrypt(data: String): String? {
val pubBuf: ByteArray = Base64.decode(pub, Base64.DEFAULT)
val key = KeyFactory.getInstance("RSA").generatePublic(X509EncodedKeySpec(pubBuf)) as RSAPublicKey
val cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding")
cipher.init(Cipher.ENCRYPT_MODE, key)
val bytes = cipher.doFinal(data.toByteArray(StandardCharsets.UTF_8))
// !!! (2)改变Base64编码参数
return Base64.encodeToString(bytes, Base64.NO_WRAP)
}
第二步、调整客户端 OAEP 填充初始化方式
经过上一步调整Android端Base64编码配置后,服务端解密还是有异常:填充错误导致解密失败。找了一圈发现了 5 年前的讨论遗址,结论大致是:Android 中 OAEP 填充的哈希算法默认使用 SHA-1,而 JDK 中是 SHA-256 ,两者不一致。这样的话只需将客户端初始化 Cipher 时需显式指定 OAEP 参数使其和 JDK 中一致即可:
Exception in thread "main" javax.crypto.BadPaddingException: Decryption error
at java.base/sun.security.rsa.RSAPadding.unpadOAEP(RSAPadding.java:497)
at java.base/sun.security.rsa.RSAPadding.unpad(RSAPadding.java:292)
at java.base/com.sun.crypto.provider.RSACipher.doFinal(RSACipher.java:366)
at java.base/com.sun.crypto.provider.RSACipher.engineDoFinal(RSACipher.java:392)
at java.base/javax.crypto.Cipher.doFinal(Cipher.java:2202)
at org.example.Main.decrypt(Main.java:74)
at org.example.Main.main(Main.java:55)
// 修改Android端Cipher的初始化参数,使其哈希算法用 SHA-256(测试通过)
@Throws(Exception::class)
fun encrypt(data: String): String? {
val pubBuf: ByteArray = Base64.decode(pub, Base64.DEFAULT)
val key = KeyFactory.getInstance("RSA").generatePublic(X509EncodedKeySpec(pubBuf)) as RSAPublicKey
val cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding")
// !!! (1)指定 OAEP 填充使用哈希算法 SHA256,并出示化 Cipher对象
val sp = OAEPParameterSpec("SHA-256", "MGF1", MGF1ParameterSpec("SHA-1"), PSource.PSpecified.DEFAULT)
cipher.init(Cipher.ENCRYPT_MODE, key, sp)
val bytes = cipher.doFinal(data.toByteArray(StandardCharsets.UTF_8))
// !!! (2)改变Base64编码参数
return Base64.encodeToString(bytes, Base64.NO_WRAP)
}
写在最后
参考 HTTPS 协议,非对称加解密性能上不如对称加解密算法,但由于此处仅用于处理用户填写的账号口令(通常长度限制在 20 以下),性能上在可接受范围。另外,如果并非用户创建 / 更新密码,则还可通过“挑战 - 应答”机制进一步降低泄露和被重放攻击的风险。