把 SpringBoot 项目打成 Jar 包交付给客户,别人执行一条命令,代码基本就摊开了:
jar -xf billing-service.jar
javap -c BOOT-INF/classes/com/demo/billing/PriceService.class
类名、方法名、SQL、接口地址,甚至某些写死的密钥,都能翻出来。
这个地方我一直不太信所谓的“Jar 一键加密”。JVM 最终必须拿到可执行的字节码,只要代码能运行,就一定存在被抓取的可能。我们能做的不是让它永远无法反编译,而是把分析成本抬高,别让人解压后五分钟就看完核心逻辑。
普通项目我一般分三层处理:代码混淆、核心类加密、敏感配置外置。
先处理混淆。
混淆会改掉类名、方法名和局部变量名。例如原来的:
public BigDecimal calculateContractPrice(Long customerId, BigDecimal amount) {
return discountRepository.findRate(customerId)
.map(amount::multiply)
.orElse(amount);
}
处理后,反编译出来可能只剩下a.a(Long, BigDecimal)。逻辑仍然能看,但阅读成本已经高了不少。
不过 Spring 项目不能一股脑全改。Controller、Entity、配置类以及被反射调用的方法,乱改后项目大概率启动失败:
NoSuchBeanDefinitionException: No qualifying bean of type 'PricePolicy'
JsonMappingException: No serializer found for class CustomerQuote
所以混淆规则里至少要保留这些内容:
-keep @org.springframework.boot.autoconfigure.SpringBootApplication class *
-keep @org.springframework.stereotype.Controller class *
-keep @org.springframework.web.bind.annotation.RestController class *
-keep @jakarta.persistence.Entity class *
-keepattributes *Annotation*,Signature,InnerClasses
混淆解决的是“看不懂”,不是“拿不到”。真正不想直接暴露的计费、授权、规则计算模块,我会单独编译,再把 class 字节加密后放进 Jar。
下面这段是构建阶段使用的加密器,示例使用 AES-GCM。这里不用 ECB,那种写法我看到基本会直接改掉。
public final class ClassSeal {
public static byte[] encrypt(byte[] classBytes, SecretKey key) throws Exception {
byte[] nonce = new byte[12];
SecureRandom.getInstanceStrong().nextBytes(nonce);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(128, nonce));
byte[] encrypted = cipher.doFinal(classBytes);
ByteBuffer packet = ByteBuffer.allocate(nonce.length + encrypted.length);
packet.put(nonce);
packet.put(encrypted);
return packet.array();
}
private ClassSeal() {
}
}
加密后的文件不要再保留.class后缀,可以放到:
BOOT-INF/classes/protected/pricing.bin
运行时由自定义类加载器读取并解密:
public final class ProtectedClassLoader extends SecureClassLoader {
private final SecretKey key;
public ProtectedClassLoader(ClassLoader parent, SecretKey key) {
super(parent);
this.key = key;
}
public Class<?> loadProtected(String className, byte[] packet) throws Exception {
ByteBuffer buffer = ByteBuffer.wrap(packet);
byte[] nonce = new byte[12];
buffer.get(nonce);
byte[] encrypted = new byte[buffer.remaining()];
buffer.get(encrypted);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(128, nonce));
byte[] classBytes = cipher.doFinal(encrypted);
return defineClass(className, classBytes, 0, classBytes.length);
}
}
这里有个坑:不要把 AES 密钥写成 Java 常量。
private static final String KEY = "1234567890abcdef";
这么写等于没加密。反编译的人先搜SecretKeySpec、Cipher.getInstance,很快就能顺着代码把密钥翻出来。
密钥至少应该由启动环境注入:
java -Dapp.seal.key="${APP_SEAL_KEY}" -jar billing-service.jar
代码里只负责读取,不提供默认值:
String rawKey = System.getProperty("app.seal.key");
if (rawKey == null || rawKey.isBlank()) {
throw new IllegalStateException("缺少运行密钥,拒绝启动");
}
更严格一点,可以让程序启动后拿机器指纹和许可证去授权服务换取临时密钥。这样客户复制一个 Jar 到另一台机器,程序也不能直接跑。
但话得说透:自定义类加载器解密后的字节码仍可能被 Java Agent、调试器或者内存转储拿走。把整个系统都塞进加密加载器,维护成本也很高,Spring 的扫描、代理、序列化随时可能出问题。
我实际更倾向于只保护少量核心模块。普通 CRUD 做混淆,数据库密码和令牌走配置中心,关键算法做加密加载,再补一层许可证校验。别把时间花在加密几十个 Controller 上,那些代码既不值钱,也最容易把启动流程折腾坏。
Jar 加密不是一堵墙,更像几道麻烦的门。门越多,对方拆代码的成本越高。做到这一步,通常已经够用了。