一次 Unity ↔ Android 基于 RSA‑OAEP 的互通踩坑记
这篇分享,记录我如何从“Base64 报错/平台不支持/解密失败”一路定位到“填充算法不一致”的根因,并给出两条稳定落地方案。同时整理了调试手册、代码片段和上线前自检清单,方便你复用。
背景
- Unity 端用公钥加密一段紧凑 JSON(i/m/e/g),得到 Base64 token。
- Android 端持有私钥,解密 token,解析 JSON,校验 iss/mode/exp 等。
- 目标:让两端在不同运行时/实现下,稳定互通。
现象与主要错误
按出现顺序,踩过这些坑:
-
Base64 无效
The input is not a valid Base-64 string…
把整段 PEM(含 BEGIN/END)直接喂给 Convert.FromBase64String 导致。 -
平台不支持导入公钥
PlatformNotSupportedException: ImportSubjectPublicKeyInfo 不被 Unity 当前运行时支持。 -
填充模式不被支持
CryptographicException: Specified padding mode is not valid for this algorithm.
Unity 的 RSACryptoServiceProvider 不支持 OAEP‑SHA256。 -
Android 解密 BadPadding/校验失败
两端 OAEP 参数不一致:Unity 用的是 OAEP‑SHA1,而 Android 端在用 OAEPWithSHA‑256(还混合了 MGF1=SHA‑1)。此外,URL/ADB 传参有时会把 Base64 的 + 变成空格,导致密文损坏。 -
字段名和时间单位差异
Unity 用 i/m/e/g,Android 用 iss/mode/exp;好在做了映射。时间戳单位是毫秒,两端一致。
根因总结
- 核心:加密填充与参数不一致(OAEP 的消息哈希、MGF1 哈希、label 必须完全一致)。
- 次要:PEM 清洗、平台 API 支持差异、Base64 在传输中被改形。
两条可落地的对齐方案
方案 A:统一 OAEP‑SHA1(最少改动)
- 适用:你当前 Unity 端已使用 OAEP‑SHA1,想快速打通。
- Android 端解密改成 SHA‑1(MGF1 也为 SHA‑1):
private fun decrypt(tokenB64: String?): String? {if (tokenB64.isNullOrBlank()) return nullval pri = getPrivateKey() ?: return nullreturn try {val cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-1AndMGF1Padding")cipher.init(Cipher.DECRYPT_MODE, pri) // 默认 MGF1(SHA-1), label=DEFAULTval raw = Base64.decode(tokenB64.trim().replace(" ", "+"), Base64.DEFAULT)val pt = cipher.doFinal(raw)String(pt, Charsets.UTF_8)} catch (e: Exception) {Log.w("AuthRsa", "decrypt fail(SHA1): ${e.message}")null}
}
- Unity 保持:
var ct = rsa.Encrypt(plain, RSAEncryptionPadding.OaepSHA1);
- 明文长度(2048 位):最大约 214 字节(k − 2×20 − 2)。
优点:改动最少,立即可用。
缺点:SHA‑1 已过时,长期建议迁到 SHA‑256。
方案 B:统一 OAEP‑SHA256(更优)
- Android 保持 SHA‑256,并明确 MGF1=SHA‑256:
val cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding")
val spec = OAEPParameterSpec("SHA-256", "MGF1", MGF1ParameterSpec.SHA256, PSource.PSpecified.DEFAULT
)
cipher.init(Cipher.DECRYPT_MODE, pri, spec)
-
Unity 端两种实现路径:
- 使用 BouncyCastle(跨平台通用)
// 引入 Portable.BouncyCastle.dll 到 Assets/Plugins using Org.BouncyCastle.Crypto; using Org.BouncyCastle.Crypto.Encodings; using Org.BouncyCastle.Crypto.Engines; using Org.BouncyCastle.Crypto.Digests; using Org.BouncyCastle.Security;string CleanPem(string pem) => Regex.Replace(pem, "-----BEGIN PUBLIC KEY-----|-----END PUBLIC KEY-----|\\s", ""); string EncryptOaepSha256WithBC(string pemPublicKey, byte[] plain) {var der = Convert.FromBase64String(CleanPem(pemPublicKey)); // SPKIvar pub = PublicKeyFactory.CreateKey(der); // RsaKeyParametersvar engine = new OaepEncoding(new RsaEngine(), new Sha256Digest(), new Sha256Digest(), null);engine.Init(true, pub);var ct = engine.ProcessBlock(plain, 0, plain.Length);return Convert.ToBase64String(ct); }
- 下放到 Android Java 插件(如果只跑 Android)
public static String encryptBase64(String spkiB64, String utf8) throws Exception {byte[] keyBytes = android.util.Base64.decode(spkiB64, Base64.DEFAULT);PublicKey pub = KeyFactory.getInstance("RSA").generatePublic(new X509EncodedKeySpec(keyBytes));Cipher cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding");cipher.init(Cipher.ENCRYPT_MODE, pub);return Base64.encodeToString(cipher.doFinal(utf8.getBytes(StandardCharsets.UTF_8)), Base64.NO_WRAP); }
- 使用 BouncyCastle(跨平台通用)
-
明文长度(2048 位):最大约 190 字节(k − 2×32 − 2)。
优点:更现代更安全。
注意:Unity 默认的 RSACryptoServiceProvider 不支持 OaepSHA256,所以需要 BC 或插件。
关键代码片段
- Unity:SPKI(“BEGIN PUBLIC KEY”) → RSAParameters 解析(如需内置实现)
static byte[] ReadSpkiFromPem(string pem) => Convert.FromBase64String(pem.Replace("-----BEGIN PUBLIC KEY-----","").Replace("-----END PUBLIC KEY-----","").Replace("\r","").Replace("\n","").Trim());static int ReadLen(BinaryReader br) {int b = br.ReadByte();if ((b & 0x80) == 0) return b;int n = b & 0x7F, len = 0; for (int i=0;i<n;i++) len = (len<<8)|br.ReadByte();return len;
}static RSAParameters SpkiToRsaParams(byte[] spki) {using var ms = new MemoryStream(spki);using var br = new BinaryReader(ms);br.ReadByte(); ReadLen(br); // SEQbr.ReadByte(); int algLen = ReadLen(br); br.ReadBytes(algLen); // AlgIdbr.ReadByte(); ReadLen(br); br.ReadByte(); // BIT STRING + unusedif (br.ReadByte()!=0x30) throw new FormatException("Bad RSAPublicKey");ReadLen(br);if (br.ReadByte()!=0x02) throw new FormatException("Bad modulus");int modLen = ReadLen(br); var mod = br.ReadBytes(modLen);if (mod.Length>0 && mod[0]==0x00) { var t=new byte[mod.Length-1]; Buffer.BlockCopy(mod,1,t,0,t.Length); mod=t; }if (br.ReadByte()!=0x02) throw new FormatException("Bad exponent");int expLen = ReadLen(br); var exp = br.ReadBytes(expLen);return new RSAParameters { Modulus = mod, Exponent = exp };
}
- Android:私钥解析(支持 PKCS#1/PKCS#8),并解密(统一 OAEP)
private fun parsePrivateKey(pemOrB64: String): PrivateKey {val text = pemOrB64.trim()val kf = KeyFactory.getInstance("RSA")return when {text.contains("BEGIN RSA PRIVATE KEY") -> { // PKCS#1val clean = text.replace("-----BEGIN RSA PRIVATE KEY-----","").replace("-----END RSA PRIVATE KEY-----","").replace("\\s".toRegex(), "")val pkcs1 = Base64.decode(clean, Base64.DEFAULT)val der = pkcs1ToPkcs8(pkcs1) // 组装成 PKCS#8kf.generatePrivate(PKCS8EncodedKeySpec(der))}text.contains("BEGIN PRIVATE KEY") -> { // PKCS#8val clean = text.replace("-----BEGIN PRIVATE KEY-----","").replace("-----END PRIVATE KEY-----","").replace("\\s".toRegex(), "")val der = Base64.decode(clean, Base64.DEFAULT)kf.generatePrivate(PKCS8EncodedKeySpec(der))}else -> { // 纯 Base64val raw = Base64.decode(text.replace("\\s".toRegex(), ""), Base64.DEFAULT)try { kf.generatePrivate(PKCS8EncodedKeySpec(raw)) }catch (_: Exception) { kf.generatePrivate(PKCS8EncodedKeySpec(pkcs1ToPkcs8(raw))) }}}
}
- Verify 调试版(指出失败阶段)
fun verify(tokenB64: String?, expectedMode: String): Pair<Boolean, Payload?> {val plain = decrypt(tokenB64)if (plain == null) { Log.w(TAG, "verify: decrypt failed"); return false to null }val p = parse(plain)if (p == null) { Log.w(TAG, "verify: json parse failed. plain(64)=${plain.take(64)}"); return false to null }val now = System.currentTimeMillis()val okIss = p.iss == "launcher"val okMode = p.mode.equals(expectedMode, true)val okExp = p.exp > nowLog.d(TAG, "verify fields: iss=${p.iss}, mode=${p.mode}, exp=${p.exp}, now=$now, okIss=$okIss, okMode=$okMode, okExp=$okExp")val ok = okIss && okMode && okExpreturn ok to if (ok) p else null
}
传输与编码注意
- 尽量用 Intent extras 传字符串;若用 URL/命令行,务必做 URL‑encode 或使用 Base64URL(-/_,去掉 =)。
- Android 端解码前做清洗:
token.trim().replace(" ", "+")
,并用Base64.DEFAULT
容忍换行。 - ADB 传参注意引号与平台差异;实在不稳,改 Base64URL。
安全与架构建议
- 不要在客户端长期持有私钥(易被逆向)。更推荐:服务端“签名”,客户端“验签”(JWT/JWS 思路)。
- 若 payload 可能变大,采用“RSA 加密随机 AES 密钥 + AES‑GCM 加密正文”的混合加密。
- 使用 Android Keystore 存私钥,限制可导出;区分测试/生产,定期轮换。
- 时间校验建议允许 1–2 分钟偏差(时钟漂移)。
上线前自检清单
- 两端 OAEP 参数一致(SHA‑1 或 SHA‑256;MGF1 相同;label 默认)。
- 明文长度未超过上限(SHA‑1≈214B,SHA‑256≈190B,2048 位密钥)。
- PEM 清洗正确(Base64 仅中间体;无隐藏字符)。
- Base64 在传输中未被改形(+ 空格、= 丢失);必要时使用 Base64URL。
- 字段映射正确(i/m/e/g ↔ iss/mode/exp/game),时间单位一致(毫秒)。
- 日志能区分 Base64/解密/JSON/字段校验失败。
- 私钥安全存储策略明确(最好不放客户端)。
常见错误对照表
现象/异常 | 常见原因 | 解决 |
---|---|---|
Base64 invalid | PEM 带 BEGIN/END/换行 | 去头尾、去空白后再解码 |
PlatformNotSupportedException | Unity 运行时不支持 ImportSubjectPublicKeyInfo | 用 RSAParameters 解析 SPKI 或使用 BouncyCastle/Android 插件 |
Specified padding mode… | RSACryptoServiceProvider 不支持 OAEP‑SHA256 | 改用 OAEP‑SHA1 或 BouncyCastle/插件实现 SHA‑256 |
BadPaddingException | OAEP 参数不一致、密钥不配、Base64 被改形 | 统一 OAEP 参数;修正传输;核对密钥 |
verify 逻辑失败 | 字段名/时间单位不一致 | 做字段映射;确认毫秒级时间戳 |
结语
这次问题的根因不在“密钥/代码对不对”,而是“Unity 与 Android 的加密参数默认值并不一致”。一旦把 OAEP 的细节(消息哈希、MGF1、label)对齐,其它问题(PEM 清洗、平台 API、Base64 传输)就都是工程实现层面的细节。
休闲一刻
祺洛管理系统介绍
祺洛是一个 Rust 企业级快速开发平台,基于(Rust、 Axum、Sea-orm、Jwt、Vue),内置模块如:部门管理、角色用户、菜单及按钮授权、数据权限、系统参数、日志管理等。在线定时任务配置;支持集群,支持多数据源,支持分布式部署。
🌐 官方网站: https://www.qiluo.vip/
让企业级应用开发更简单、更高效、更安全
🌟 如何支持项目?
如果您觉得祺洛Admin的技术方案有价值,或是能解决您在企业级开发中的实际问题,欢迎通过以下方式支持项目发展:
-
点亮Star — 访问我们的代码仓库,点击右上角的Star按钮,这是对开源项目最直接的认可,也能帮助更多人发现这个项目:
- GitCode仓库:https://gitcode.com/will_csdn_go/qiluo_admin.git
- Gitee仓库:https://gitee.com/chenlunfu/qiluo_admin.git
- GitHub仓库:https://github.com/chelunfu/qiluo_admin.git
-
参与贡献 — 无论是提交Issue反馈问题,还是PR贡献代码,都是对项目成长的重要支持
-
分享传播 — 将项目推荐给有需要的同事或朋友,让更多人受益于这个开发框架
您的每一份支持,都是我们持续优化迭代的动力。祺洛Admin团队感谢您的关注与支持!