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

发布于:2025-08-15 ⋅ 阅读:(16) ⋅ 点赞:(0)

这篇分享,记录我如何从“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 null
    val pri = getPrivateKey() ?: return null
    return try {
        val cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-1AndMGF1Padding")
        cipher.init(Cipher.DECRYPT_MODE, pri) // 默认 MGF1(SHA-1), label=DEFAULT
        val 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));   // SPKI
          var pub = PublicKeyFactory.CreateKey(der);                    // RsaKeyParameters
          var 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); // SEQ
    br.ReadByte(); int algLen = ReadLen(br); br.ReadBytes(algLen); // AlgId
    br.ReadByte(); ReadLen(br); br.ReadByte(); // BIT STRING + unused
    if (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#1
            val 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#8
            kf.generatePrivate(PKCS8EncodedKeySpec(der))
        }
        text.contains("BEGIN PRIVATE KEY") -> { // PKCS#8
            val 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 -> { // 纯 Base64
            val 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 > now
    Log.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 && okExp
    return 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的技术方案有价值,或是能解决您在企业级开发中的实际问题,欢迎通过以下方式支持项目发展:

  1. 点亮Star — 访问我们的代码仓库,点击右上角的Star按钮,这是对开源项目最直接的认可,也能帮助更多人发现这个项目:

  2. 参与贡献 — 无论是提交Issue反馈问题,还是PR贡献代码,都是对项目成长的重要支持

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

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


网站公告

今日签到

点亮在社区的每一天
去签到