当前位置: 首页 > news >正文

一次 Unity ↔ Android 基于 RSA‑OAEP 的互通踩坑记

这篇分享,记录我如何从“Base64 报错/平台不支持/解密失败”一路定位到“填充算法不一致”的根因,并给出两条稳定落地方案。同时整理了调试手册、代码片段和上线前自检清单,方便你复用。


背景

  • Unity 端用公钥加密一段紧凑 JSON(i/m/e/g),得到 Base64 token。
  • Android 端持有私钥,解密 token,解析 JSON,校验 iss/mode/exp 等。
  • 目标:让两端在不同运行时/实现下,稳定互通。

现象与主要错误

按出现顺序,踩过这些坑:

  1. Base64 无效
    The input is not a valid Base-64 string…
    把整段 PEM(含 BEGIN/END)直接喂给 Convert.FromBase64String 导致。

  2. 平台不支持导入公钥
    PlatformNotSupportedException: ImportSubjectPublicKeyInfo 不被 Unity 当前运行时支持。

  3. 填充模式不被支持
    CryptographicException: Specified padding mode is not valid for this algorithm.
    Unity 的 RSACryptoServiceProvider 不支持 OAEP‑SHA256。

  4. Android 解密 BadPadding/校验失败
    两端 OAEP 参数不一致:Unity 用的是 OAEP‑SHA1,而 Android 端在用 OAEPWithSHA‑256(还混合了 MGF1=SHA‑1)。此外,URL/ADB 传参有时会把 Base64 的 + 变成空格,导致密文损坏。

  5. 字段名和时间单位差异
    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 端两种实现路径:

    1. 使用 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);
      }
      
    2. 下放到 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);
      }
      
  • 明文长度(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 invalidPEM 带 BEGIN/END/换行去头尾、去空白后再解码
PlatformNotSupportedExceptionUnity 运行时不支持 ImportSubjectPublicKeyInfo用 RSAParameters 解析 SPKI 或使用 BouncyCastle/Android 插件
Specified padding mode…RSACryptoServiceProvider 不支持 OAEP‑SHA256改用 OAEP‑SHA1 或 BouncyCastle/插件实现 SHA‑256
BadPaddingExceptionOAEP 参数不一致、密钥不配、Base64 被改形统一 OAEP 参数;修正传输;核对密钥
verify 逻辑失败字段名/时间单位不一致做字段映射;确认毫秒级时间戳

结语

这次问题的根因不在“密钥/代码对不对”,而是“Unity 与 Android 的加密参数默认值并不一致”。一旦把 OAEP 的细节(消息哈希、MGF1、label)对齐,其它问题(PEM 清洗、平台 API、Base64 传输)就都是工程实现层面的细节。

休闲一刻

祺洛管理系统介绍

祺洛是一个 Rust 企业级快速开发平台,基于(Rust、 Axum、Sea-orm、Jwt、Vue),内置模块如:部门管理、角色用户、菜单及按钮授权、数据权限、系统参数、日志管理等。在线定时任务配置;支持集群,支持多数据源,支持分布式部署。
🌐 官方网站: https://www.qiluo.vip/
让企业级应用开发更简单、更高效、更安全

🌟 如何支持项目?

如果您觉得祺洛Admin的技术方案有价值,或是能解决您在企业级开发中的实际问题,欢迎通过以下方式支持项目发展:

  1. 点亮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
  2. 参与贡献 — 无论是提交Issue反馈问题,还是PR贡献代码,都是对项目成长的重要支持

  3. 分享传播 — 将项目推荐给有需要的同事或朋友,让更多人受益于这个开发框架

您的每一份支持,都是我们持续优化迭代的动力。祺洛Admin团队感谢您的关注与支持!

http://www.lryc.cn/news/621044.html

相关文章:

  • 【题解】P1000 超级玛丽游戏 题解
  • 2025中国快递物流智能装备产业发展论坛将于9月3日上海举办
  • 如何选择图表库|2025 年实现强大数据可视化的 6 个 JavaScript 图表库对比
  • 二进制与进制转换
  • SpringBoot+Vue线上部署MySQL问题解决
  • WinForm之自定义布局(了解)
  • Centos9傻瓜式linux部署CRMEB 开源商城系统(PHP)
  • C++ 仿RabbitMQ实现消息队列项目
  • ClickHouse 日常运维命令总结
  • JMeter性能测试详细版(适合0基础小白学习--非常详细)
  • 前端css学习笔记5:列表表格背景样式设置
  • 回归算法:驱动酒店智能化定价与自动化运营的引擎—仙盟创梦IDE
  • 手写MyBatis第17弹:ResultSetMetaData揭秘:数据库字段到Java属性的桥梁
  • uniapp对接极光消息推送
  • Webpack Plugin 深度解析:从原理到实战开发指南
  • 读取Kaggle下载的数据集(数据的读取 f’{path}\\CMaps\\train_FD001.txt’)
  • mlir operand
  • Day54 Java面向对象08 继承
  • Java中Record的应用
  • 机器翻译:回译与低资源优化详解
  • Java 8 新特性介绍
  • 51单片机-驱动LED模块教程
  • 广义矩估计随机近似中公式(2d)的推导
  • Linux入门DAY24
  • Python中的函数入门二
  • 小白做亚马逊广告,空烧成本不出单怎么办
  • 20道JavaScript进阶相关前端面试题及答案
  • DataHub IoT Gateway:工业现场设备与云端平台安全互联的高效解决方案
  • Git 中切换到指定 tag
  • 电子电路学习日记