当前位置: 首页 > 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.dtcms.com/a/331519.html

相关文章:

  • Android ADB 常用指令全解析
  • ADB服务端调试
  • markdown格式中table表格不生效,没有编译的原因
  • Mybatis Plus 分页插件报错`GOLDILOCKS`
  • 视频号主页的企业信息如何设置?
  • 深入了解linux系统—— 线程概念
  • Fiddler抓包
  • nginx --ssl证书生成mkcert
  • PCB爆板产生的原因有哪些?如何预防?
  • 第三十一天(系统io)
  • Qwen2-VL-2B 轻量化部署实战:数据集构建、LoRA微调、GPTQ量化与vLLM加速
  • 归并排序专栏
  • 机器学习基础讲解
  • Java -- HashSet的全面说明-Map接口的常用方法-遍历方法
  • feed-forward系列工作集合与跟进(vggt以后)
  • 第二十三天:求逆序对
  • Day54 Java面向对象08 继承
  • 附:日期类Date的实现
  • Pytorch在FSDP模型中使用EMA
  • Leetcode_1780.判断一个数字是否可以表示成三的幂的和
  • UE5 C++ 删除文件
  • BotCash:GPT-5发布观察 工程优化的进步,还是技术突破的瓶颈?
  • Spring Boot + Redis Cluster 测试
  • 回流(Reflow)与重绘(Repaint):浏览器渲染性能优化核心
  • 演员念真主演《镇恶追凶》辽宁杀青
  • 数字电路上的通讯速度是越快越好还是越慢越好?
  • 【二分图】染色问题
  • 企业智脑UMI AIGC SaaS:解锁AI时代全场景生产力,中小微企业转型利器
  • Linux学习-多任务(进程)
  • **隐私沙盒:发散创新之光**随着互联网技术的飞速发展,数据安全和隐私保护逐渐成为人们关注的焦点。隐私沙盒作为一种新兴