控制台 退出登录

【实战拆解】自制证书+power插件,彻底告别“改一次数据写一条规则”

1. 为什么还要写这一篇?

在上一篇《无需私钥也能通过 RSA 验签?power 插件原理与规则构造全记录》里,我们做到了“不用私钥也能通过 RSA 验签”:只要在 power.conf 里配一条 EQUAL 规则,就能让修改后的激活码绕过阶段 2 的数据签名校验。

但上一篇的方案有个明显的代价:每次修改数据,都要重新算一次规则。

简单回顾一下上一篇的流程:

  1. 修改 licensePartBase64 里的日期
  2. 重新计算 SHA-1 哈希
  3. 构造 DigestInfo
  4. 做 PKCS#1 填充
  5. 把结果写成一条 EQUAL, x, y, z -> fakeResult 规则

如果你只是想改一次日期,写一条规则,完全能忍。

但如果你的需求是反复签发不同数据的激活码呢?每次都要重复“提取签名值 → 计算 DigestInfo → 组装规则”这条链,弄个三五次你估计就烦了。

于是问题就变成了:能不能一劳永逸?

能,而且解法比你想的要直接:自己当 CA。

这次我们直接回到验签流程的起点:阶段 1:证书链校验。

如果我们能做一张自己的证书,然后用配套的私钥去签名数据,那就不需要 power 规则来“伪造验签结果”了:我们本身就是合法的签名者。

但这里还有一个坎:我们的自制证书不在官方根 CA 的信任链里。验签程序拿到我们的证书,往上追溯,发现根证书不是它信任的那个,会直接拒绝。

怎么过这个坎?相信你已经猜到了:用一条 power 规则,让官方根证书的验签“短路”

所以本篇的逻辑就是:

自制一张证书 → 用自制证书的私钥签名数据 → 用一条 power 规则让官方根证书“相信”我们的证书 → 从此不再为每一次数据修改写规则。

废话不多说,直接开干。

2. 先看官方证书长啥样

动手之前,得先知道我们模仿的对象长什么样。

CheckLicense 类里,证书加载的逻辑大概是这样:

CertificateFactory cf = CertificateFactory.getInstance("X.509");
X509Certificate cert = (X509Certificate) cf.generateCertificate(
    new ByteArrayInputStream(certBase64.getBytes())
);

我加了一行打印,把证书内容直接打到控制台:

System.out.println(cert);

跑起来之后,控制台会输出两张证书:一张是根证书,一张是终端实体证书

根证书(Root CA):

[
  Version: V3
  Subject: CN=JetProfile CA
  Signature Algorithm: SHA256withRSA, OID = 1.2.840.113549.1.1.11

  Key:  Sun RSA public key, 4096 bits
  modulus: 860106576952879101192782278876319243486...............
  public exponent: 65537
  Validity: [From: Fri Oct 02 19:00:56 MYT 2015,
               To: Tue Oct 24 19:00:56 MYT 2045]
  Issuer: CN=JetProfile CA
  SerialNumber: [    d26cb183 b28379e1]
]

终端实体证书(End-Entity):

[
  Version: V3
  Subject: CN=prod2y-from-20240920
  Signature Algorithm: SHA256withRSA, OID = 1.2.840.113549.1.1.11

  Key:  Sun RSA public key, 2048 bits
  modulus: 23642313805943905243041450226779686298................
  public exponent: 65537
  Validity: [From: Fri Sep 20 20:11:27 MYT 2024,
               To: Tue Sep 22 20:11:27 MYT 2026]
  Issuer: CN=JetProfile CA
  SerialNumber: [    11]
]

两个关键信息:

  1. 根证书是自签的,SubjectIssuer 都是 CN=JetProfile CA,有效期直接拉到 2045 年。
  2. 终端证书由根证书签发,有效期两年,2048 位 RSA。

我们要做的就是仿造这个结构,生成一套自己的证书。

3. 用 keytool 做一套证书

JDK 自带的 keytool 就够用了,不用装别的工具。

整个流程分三步:生成根证书 → 生成终端证书 → 用根证书签终端证书。

第 1 步:生成根证书(自签名)

keytool -genkeypair -alias root -keyalg RSA -keysize 4096 \
  -sigalg SHA256withRSA \
  -dname "CN=JetProfile CA" \
  -ext BC=CA:true \
  -ext KU=keyCertSign,cRLSign \
  -validity 730 \
  -keypass 123456 \
  -keystore root.jks \
  -storepass 123456 \
  -storetype jks

参数拆开看:

参数含义
-alias root别名,后面引用方便
-keysize 4096根证书用 4096 位,跟官方一致
-ext BC=CA:true标记这是一张 CA 证书
-ext KU=keyCertSign,cRLSign证书用途:签发证书和吊销列表

然后导出根证书的 PEM 文件,后面要用:

keytool -exportcert -alias root -keystore root.jks \
  -storepass 123456 -storetype jks -rfc > root.pem

第 2 步:生成终端证书

keytool -genkeypair -alias ca -keyalg RSA -keysize 2048 \
  -sigalg SHA256withRSA \
  -dname "CN=prod2y-from-20240920" \
  -validity 730 \
  -keypass 123456 \
  -keystore ca.jks \
  -storepass 123456 \
  -storetype jks

注意 -dname 跟官方证书的 Subject 保持一致:CN=prod2y-from-20240920

这一步生成的终端证书还是自签的,还不是由根证书签发的。下一步用根证书的私钥去签它。

第 3 步:用根证书签署终端证书

keytool -certreq -sigalg SHA256withRSA -alias ca \
  -keystore ca.jks -storepass 123456 \
  | keytool -gencert -sigalg SHA256withRSA -alias root \
  -ext BC=CA:false \
  -ext EKU=serverAuth \
  -ext KU=digitalSignature,keyEncipherment \
  -keystore root.jks -storepass 123456 -rfc > ca.pem

然后导入信任链:

# 先把根证书导进去,让终端证书信任它
keytool -importcert -noprompt -alias root \
  -keystore ca.jks -storepass 123456 -file root.pem

# 再把由根签过的终端证书导进去,覆盖原来的自签版本
keytool -importcert -alias ca \
  -keystore ca.jks -storepass 123456 -file ca.pem

验证一下:

keytool -printcert -v -file root.pem
keytool -printcert -v -file ca.pem
keytool -list -v -storepass 123456 -keystore root.jks -alias root
keytool -list -v -storepass 123456 -keystore ca.jks -alias ca

到这里,我们就有两套证书了:

证书密钥库是否含私钥用途
根证书root.jks签其他证书
终端证书ca.jks签激活码

证书准备好了,但验签程序还不认它:因为它的根不是官方根 CA。

接下来解决这个问题。

4. 让自制证书“骗过”官方根证书的验证

验签程序在做证书链校验的时候,逻辑是这样的:

  1. 拿到终端证书,看到 IssuerCN=JetProfile CA
  2. 去找 CN=JetProfile CA 的根证书
  3. 用根证书的公钥去验终端证书的签名
  4. 验签通过 → 证书合法

我们的自制证书,Issuer 也是 CN=JetProfile CA但验签程序用的是官方根证书的公钥,不是我们自己的公钥

官方根证书的公钥和我们自制证书的私钥不是一对,验签必然失败。

这时候 power 插件就该登场了。

我们在上一篇已经验证过一个结论:验签程序只检查 sig.modPow(e, n) 的结果,不关心这个结果是“算出来的”还是“被替换的”。

把这个结论套到证书链校验里:

  • sig = 自制证书的签名值(ca.getSignature()
  • (e, n) = 官方根证书的公钥(root2.getPublicKey()
  • sig.modPow(e, n) 的期望结果 = 自制证书内容的 DigestInfo

我们不需要用官方根证书的私钥去签我们的证书:我们只需要让 sig.modPow(e, n) 返回一个正确的 DigestInfo 就行了。

DigestInfo 是可以算出来的:用我们自制证书的 TBSCertificate 算一个 SHA-256 哈希,再按标准格式编码成 DigestInfo

这跟上一篇算激活码的 DigestInfo 思路完全一样,只是数据来源换成了证书。

生成 power 规则的代码:

static void genPowerConf(X509Certificate ca, X509Certificate root, X509Certificate root2)
        throws NoSuchAlgorithmException {
    // 自制证书的签名值(验签程序的输入)
    BigInteger signature = new BigInteger(1, ca.getSignature());

    // 官方根证书的公钥(验签程序实际用的钥匙)
    RSAPublicKey root2PublicKey = (RSAPublicKey) root2.getPublicKey();

    // 自制证书的 TBSCertificate 的 SHA-256 哈希(验签程序期望的“正确答案”)
    MessageDigest md = MessageDigest.getInstance("SHA-256");
    byte[] tbsCert = ca.getTBSCertificate();
    byte[] expectedDigest = md.digest(tbsCert);

    // 构造 SHA-256 的 DigestInfo(OID + 哈希值)
    byte[] sha256Oid = {0x30, 0x31, 0x30, 0x0D, 0x06, 0x09, 0x60,
                        (byte)0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02,
                        0x01, 0x05, 0x00, 0x04, 0x20};
    byte[] digestInfo = new byte[sha256Oid.length + expectedDigest.length];
    System.arraycopy(sha256Oid, 0, digestInfo, 0, sha256Oid.length);
    System.arraycopy(expectedDigest, 0, digestInfo, sha256Oid.length, expectedDigest.length);

    // PKCS#1 填充(4096 位根证书 → 512 字节)
    byte[] padded = padV15(digestInfo, 512);
    BigInteger fakeResult = new BigInteger(1, padded);

    // 组装 power 规则
    StringBuffer sb = new StringBuffer();
    sb.append("EQUAL");
    sb.append(",");
    sb.append(signature);               // x:自制证书的签名值
    sb.append(",");
    sb.append(root2PublicKey.getPublicExponent());  // y:官方根证书的公钥指数
    sb.append(",");
    sb.append(root2PublicKey.getModulus());         // z:官方根证书的模数
    sb.append("->");
    sb.append(fakeResult);              // fakeResult:预制的 DigestInfo

    System.out.println(sb.toString());
}

这条规则的含义很直白:

当验签程序用官方根证书的公钥 (e, n) 去验自制证书的签名值 sig 时,不要真的算 sig.modPow(e, n),直接返回我预制好的 DigestInfo

因为预制 DigestInfo 正好对应自制证书的内容哈希,后续的比对必然通过。

到这一步,我们的自制证书在验签程序眼里,就已经是一张“由官方根证书签发”的合法证书了。

5. 一劳永逸:用私钥签自己的激活码

证书被“认证”通过后,我们就有了两把可用的私钥:

私钥所在文件能做什么
根证书私钥root.jks签发更多终端证书
终端证书私钥ca.jks签署激活码数据

而我们最需要的是第二把:ca.jks 里的私钥。

回到上一篇的场景:修改了激活码里的日期,但没有私钥重新签名,只能靠 power 规则来“偷换”验签结果。

现在不同了。有了终端证书的私钥,我们可以真正地签名

  1. 修改 licensePartBase64 里的日期
  2. ca.jks 里的私钥对修改后的数据做 SHA1withRSA 签名
  3. 把新的签名值拼回 key 文件
  4. 验签程序用我们的终端证书(已通过 power 规则被认定为合法)去验签 → 通过

跟上一篇的方案对比如下:

上一篇这一篇
有私钥吗?✅(自制证书的私钥)
修改数据后重新算 DigestInfo 写 power 规则直接用私钥签名
每次修改数据要重新算规则不需要,签一下就完
power 规则用于阶段2(数据验签)阶段1(证书链校验)
power 规则数量每次改数据都要改固定一条,终生有效

这就是“一劳永逸”的真正含义:

一条 power 规则保护证书链校验,一把私钥解放所有后续的数据签名。

6. 两篇串起来看

两篇文章走完,CheckLicense 的两阶段校验都被我们摸透了:

阶段1:证书链校验

  • 问题:自制证书不在官方根 CA 信任链里
  • 解法:一条 power 规则,让官方根证书的公钥验签“短路”
  • 效果:自制证书被认定为合法证书

阶段2:数据签名校验

  • 问题:修改数据后没有私钥重新签名
  • 解法:用自制终端证书的私钥直接签名
  • 效果:任意修改后的数据都能生成合法签名

两篇加起来,完整的路径是这样的:

  1. 从合法 key 文件里提取官方根证书的公钥 (e, n)
  2. 自制一套证书,用 power 规则让它通过阶段 1 的校验
  3. 用自制证书的私钥签署任意修改后的数据,通过阶段 2 的校验

再回头看一下上一篇开头的问题:“修改日期后验签失败,能不能让它在没有私钥的情况下通过?”

现在有两个答案了:

  • 只改一次 → 上一篇的规则法够用
  • 长期维护 → 这一篇的证书法才是王道

免责声明

本文内容仅供技术研究与学术探讨,旨在帮助开发者理解 RSA 验签流程与 Java 安全机制。作者不支持、不鼓励、不教唆任何形式的软件破解或侵权行为。请勿将文中所述技术用于任何违反当地法律法规、侵犯他人知识产权或破坏软件许可协议的行为。读者因不当使用而产生的任何法律后果,均与作者无关。

本文采用 CC BY-NC-SA 4.0 协议发布

相关文章

【实战拆解】无需私钥也能通过RSA验签?power插件原理与规则构造全记录

【实战拆解】无需私钥也能通过RSA验签?power插件原理与规则构造全记录