做Java商业软件的人迟早要面对同一个问题程序是卖出去了但怎么保证只有买它的人能用试用期到了怎么自动停客户把安装包转手给另一家公司你该怎么止损我去年给公司的Java客户端系统补授权模块时一开始也动过自己手写校验逻辑的念头后来调研到TrueLicense才发现Java工程里的License授权机制早就有了一套现成的、不依赖联网的成熟方案。所谓License本质就是一张数字授权凭证开发商用私钥签发程序内置公钥校验离线环境也能跑通。这篇文章就以TrueLicense为工具把从密钥生成、许可证签发到Java程序内置校验的完整链路拆开讲清楚同时把我实际踩过的坑一并交代出来适合正在做Java商业软件授权模块的同学参考也适合想在项目里快速接入License机制的开发者。1. 为什么需要自研License授权业务场景与场景拆解1.1 商业软件分发中License要解决的三个核心问题先说说我遇到的真实场景。我们有一款面向企业客户部署的Java应用客户环境基本都在内网完全没法依赖云端激活。最初没有License机制程序让销售拷给客户就完事了结果第二年客户B拿着从客户A那儿刻录的光盘来找我们做售后。这种哑巴亏一次就够让你下决心上授权机制。细拆下来License要解决的核心问题就三条防止拷贝复用。Java字节码天然容易被复制但授权文件不能。只要授权校验严格拷走的程序在别的机器上要么启动不了要么功能受限。控制试用期与续费节奏。销售说免费试用30天技术上就要保证第31天真的用不了否则试用就成了永久免费。授权对象可审计、可追溯。授权给了哪个客户、几台机器、什么时候到期这些信息要么管理员能查要么程序内部能读到出了问题能追溯到源头。这些问题不解决商业软件卖得越多越亏。而解决它们的办法就是一套可签、可验、可失效的License机制。1.2 TrueLicense与硬件狗、联网激活的选型对比做Java授权方案比较成熟的可选项大致就这几种方案优点缺点适用场景加密狗硬件破解难度高、一机一狗成本高、物流麻烦、驱动兼容问题多高价工业软件、专业设计工具联网激活可控性强、能收集使用数据依赖服务器内网断网就没法用纯在线SaaS、有强制联网条件的软件自研证书签名校验离线可用、成本低所有逻辑都要自己写细节容易漏有研发资源、需要完全可控的团队TrueLicense离线授权离线可用、API成熟、签名机制可靠防破解强度依赖私钥保管内网部署的商用Java应用很多做Java商业软件的项目组最后都选了TrueLicense这条路。原因很朴素它把证书生成、私钥签名、公钥校验、过期判断这些通用工作封装成了Java类库我们只需要准备密钥库、填参数、调API就能跑通一套完整的离线授权体系不用自己从零实现RSA签名和证书管理。1.3 授权粒度与生命周期怎么定动手写代码之前先想清楚你要卖什么、怎么卖。最常见的授权粒度有三种按时间授权试用30天、按年订阅到期自动失效。按用户数/使用量授权比如授权50个用户多了就不让登录对应TrueLicense里的consumerAmount字段。按机器绑定授权在一机一码的场景下把机器的硬件指纹写进授权文件校验时读取本机指纹比对防止一个License多台机器用。授权对象的生命周期通常分为签发、发布、安装、校验、到期续期五个阶段。开发商用自己的私钥签发license文件并交付给客户客户把文件放到指定位置程序启动时加载并校验到期后要么停用要么让客户付费后重新签发一份覆盖原文件。理解了这张大图后面每段代码在干什么心里就有数了。2. 加密原理先行公钥与私钥是如何让License不可伪造的2.1 一次签名、处处验证TrueLicense的核心原理一句话说就是私钥签名、公钥验签。这里可以用一个生活化的类比开发商手里有一枚原子印章私钥签发License时往授权信息上盖一个章程序内部不存印章只存一枚验印的放大镜公钥。任何人改动了授权信息里的一个字盖章的纹路就对不上程序直接判定无效。因为非对称加密决定了公钥无法反推私钥所以即使客户把程序反编译了把公钥拿出来了也造不出一份合法的授权文件。落到技术细节上签发流程大致是把授权内容对象序列化计算摘要用私钥对摘要做签名然后连同公钥证书一起打包成License文件校验流程则是用公钥解开签名、比对内容摘要再检查有效期。这里面每一步都由TrueLicense的API封装好了我们不需要自己写加密算法。2.2 License文件到底是个什么东西很多人第一次接触License会以为它是一个不可读的加密文件。实际上TrueLicense生成出来的License文件本质是一个ZIP/JAR结构的容器。扩展名虽然叫.lic你可以试着用解压工具打开它里面能看到授权信息、签名数据、公钥证书等条目。这个认知很重要因为它能帮你正确理解防篡改机制License不是不能改而是改了就校验失败。有人会想我把过期时间改成2099年再封回去不就行了但任何内容的修改都会让签名校验挂掉程序依然能识别出来这份授权被动过手脚。安全性不在于文件打不开而在于签名不可伪造。2.3 Subject和Extra决定授权的业务边界TrueLicense里有两个参数决定了一份License的业务边界。Subject可以理解为这份License是授权给哪个应用或哪个模块的。签发和校验收到的subject必须完全一致否则校验直接失败。我见过不少新手在这里栽跟头——本地生成本地校验没问题换一个工程就报错一查两个地方的subject大小写不一样。Extra则是一个MapString, String用来承载业务上的附加信息。比如客户编号、项目经理姓名、机器指纹等。校验通过后这些信息能从LicenseContent里原样读出来非常实用。另外还有Holder持有人、Issuer签发人、ConsumerType使用类型、ConsumerAmount使用数量这些字段共同拼出授权信息的主体部分也都会被序列化和签名保护改不了。2.4 失效机制时间也不是改一改就完事LicenseContent里有Issued生效时间和NotAfter到期时间两个字段。真实验证时程序会检查当前系统时间是否落在[生效时间到期时间]这个区间内不在区间内就判定为授权无效。所以时间是License机制里最依赖本地环境的一个变量也正因如此后面实战部分我们会专门做防时间篡改的策略只校验一次是不够的。3. 环境准备用KeyTool生成密钥对并搭建License生命周期3.1 三个keytool命令搞定密钥对与公钥库要开始实战第一步是准备密钥。JDK自带的keytool工具就够了不需要额外引入任何加密软件。整个过程就三条命令。# 第一步生成私钥库有效期设为3650天 keytool -genkeypair -keysize 2048 -validity 3650 \ -alias privateKey \ -keystore privateKeys.keystore \ -storepass storepass -keypass keypass \ -dname CNlicense-admin, OURD, OExample Inc, LBeijing, STBeijing, CCN # 第二步从私钥库导出公钥证书 keytool -exportcert -alias privateKey \ -keystore privateKeys.keystore \ -storepass storepass \ -file certfile.cer # 第三步建立公钥库导入公钥证书将来内置到Java程序中 keytool -importcert -alias publicCert \ -file certfile.cer \ -keystore publicCerts.keystore \ -storepass storepass这三条命令里面有几个参数需要理解-keysize 2048RSA密钥长度2048位在目前的安全场景下是底线。-validity 3650密钥对本身的有效期单位是天3650天就是10年。这个时间要覆盖你卖License的周期建议给足。-storepass和-keypass前者保护密钥库这个文件后者保护库里的密钥条目。两者可以一样但代码里必须分清。-alias每个密钥在库里的名字后面代码加载时要用。-dname证书里的身份信息会写进证书中可以理解为这张证书是谁的。第1步生成的是私钥库必须由开发商自己保管第3步生成的是公钥库里面只有公钥证书没有私钥可以放心打进程序的resources里。流程背后的逻辑是签发用私钥校验用公钥。客户手里永远只有公钥和License文件拿不到私钥也就无法伪造授权。3.2 密钥文件的保管规范这里必须强调一下文件管理的红线。privateKeys.keystore一旦泄露你的整套授权体系等于裸奔——任何人都能用它签发合法License。所以这个文件不允许进入Git仓库哪怕私有的也不行不允许打进安装包、Docker镜像、交付物里只放在授权中心/运营管理端的专用目录由专人管理。公钥库publicCerts.keystore则相反它要打进Java工程的resources或classpath里每个程序实例都需要它来校验License。我把这个原则总结成一句话私钥藏起来公钥放出去License单独发。3.3 授权生命周期管理从运营视角看一套License机制要管理的生命周期是清晰的阶段动作产物负责方签发运营人员填参数、调生成接口license.lic开发商发布通过邮件/交付物发License给客户license.lic销售或实施安装客户将License放到指定目录license.lic客户/实施人员校验程序启动时加载并验证签名、有效期校验结果程序内续期/吊销重新生成新License覆盖旧文件新license.lic开发商注意续期不是修改原License的过期时间而是重新生成一份新的License覆盖掉客户目录下的旧文件。因为旧文件的内容一旦有任何改动签名校验就会失败所以正确的续期姿势就是重签。4. 实战撸码License生成端核心实现4.1 引入依赖生成端和校验端最终要集成进两套代码里一套是公司的授权管理后台一套是你的Java商业应用。但底层依赖是同一个Maven坐标。dependency groupIdde.schlichtherle.licensing/groupId artifactIdtruelicense/artifactId version1.82/version /dependency1.82是目前网上资料最多、踩坑经验最全的版本。如果你的项目是JDK 8及以上一般不会有兼容性问题。它内部会传递引入commons-logging、commons-codec等库如果和项目已有依赖冲突后面第6章给排查办法。4.2 定义LicenseCreatorParam参数实体生成License先定义一个参数实体把运营人员要填的信息收口在一个地方。public class LicenseCreatorParam { /** 授权对象即你的应用名校验时必须完全一致 */ private String subject; /** 私钥库文件路径 */ private String privateKeysStorePath; /** 密钥库口令 */ private String storepass; /** 密钥访问口令 */ private String keypass; /** 私钥别名 */ private String alias; /** License输出路径 */ private String licensePath; /** 生效时间 */ private Date issuedTime; /** 到期时间 */ private Date expiryTime; /** 使用类型 */ private String consumerType User; /** 使用数量 */ private int consumerAmount 1; /** 持有人 */ private String holder; /** 签发人 */ private String issuer; /** 附加业务信息比如客户编号、机器指纹 */ private MapString, String extra new HashMap(16); // 省略 getter / setter }字段含义在注释里写得很清楚实际使用中你还会发现这个实体的价值把授权配置从代码里抽离出来交给运营去填。签发界面或者配置文件里填的内容最后都会映射到这个对象上。4.3 自定义LicenseManager并暴露create/verify接下来定义一个CustomLicenseManager继承TrueLicense的LicenseManager。在部分版本中create和verify方法是protected的子类里把它们重写为public外部代码才能直接调用。import de.schlichtherle.license.LicenseContent; import de.schlichtherle.license.LicenseException; import de.schlichtherle.license.LicenseManager; import de.schlichtherle.license.LicenseParam; import java.io.File; public class CustomLicenseManager extends LicenseManager { public CustomLicenseManager(LicenseParam param) { super(param); } Override public synchronized byte[] create(LicenseContent content, File licenseFile) throws LicenseException { return super.create(content, licenseFile); } Override public synchronized LicenseContent verify(File licenseFile) throws LicenseException { return super.verify(licenseFile); } Override public synchronized LicenseContent verify(byte[] key) throws LicenseException { return super.verify(key); } }这段代码本身很简单它的作用就是把能力暴露出来。核心逻辑全在父类里签名、打包、校验、过期判断都由TrueLicense按标准流程执行。4.4 生成License的完整代码生成端服务类的核心逻辑如下import de.schlichtherle.license.DefaultLicenseParam; import de.schlichtherle.license.KeyStoreParam; import de.schlichtherle.license.LicenseContent; import de.schlichtherle.license.LicenseManager; import de.schlichtherle.license.LicenseParam; import java.io.File; import java.io.FileInputStream; import java.io.InputStream; import java.util.HashMap; public class LicenseCreatorService { public boolean generate(LicenseCreatorParam param) { try { // 1. 封装密钥库访问参数生成端指向私钥库 KeyStoreParam keyStoreParam new KeyStoreParam() { Override public String getAlias() { return param.getAlias(); } Override public String getStorePwd() { return param.getStorepass(); } Override public String getKeyPwd() { return param.getKeypass(); } Override public InputStream getStream() throws java.io.IOException { return new FileInputStream(param.getPrivateKeysStorePath()); } }; // 2. 创建License管理对象 LicenseParam licenseParam new DefaultLicenseParam( param.getSubject(), new HashMap(16), keyStoreParam); LicenseManager manager new CustomLicenseManager(licenseParam); // 3. 组装授权内容 LicenseContent content new LicenseContent(); content.setSubject(param.getSubject()); content.setHolder(param.getHolder()); content.setIssuer(param.getIssuer()); content.setConsumerType(param.getConsumerType()); content.setConsumerAmount(param.getConsumerAmount()); content.setIssued(param.getIssuedTime()); content.setNotAfter(param.getExpiryTime()); content.setExtra(param.getExtra()); // 4. 生成License文件 manager.create(content, new File(param.getLicensePath())); return true; } catch (Exception e) { // 记日志生成License失败 e.printStackTrace(); return false; } } }这段代码的关键点在于第1步里getStream()返回的是privateKeys.keystore的输入流这就是签发端的核心身份凭证第2步DefaultLicenseParam的三个参数分别是subject、extraMap和KeyStoreParam注意subject在这里就定下来了第3步LicenseContent里的字段会参与序列化和签名这就是盖到授权信息上的章第4步manager.create(content, licenseFile)执行签名与打包输出License文件。调用的时候运营人员只需要构造一个LicenseCreatorParam设置好授权对象、客户名、到期时间调用generate()即可。生成成功后把license.lic发给客户签发工作就结束了。5. 实战撸码Java工程内置校验与授权信息读取5.1 校验端的参数实体与公钥库加载授权模块做成之后下一步就是把它嵌进真正的Java商业应用里。校验端需要一个独立的参数实体它和生成端的差异在于指向公钥库而不是私钥库。public class LicenseVerifyParam { /** 授权对象必须与生成时一致 */ private String subject; /** 公钥库路径支持 classpath: 前缀 */ private String publicKeysStorePath classpath:publicCerts.keystore; /** 密钥库口令 */ private String storepass storepass; /** 公钥库没有独立keypass填充storepass即可 */ private String keypass storepass; /** 导入公钥证书时的别名 */ private String alias publicCert; /** License文件路径 */ private String licensePath /data/license/license.lic; // 省略 getter / setter }特别说一下publicKeysStorePath。公钥库一定要打进程序的resources目录所以校验代码里最常见的问题是new FileInputStream(publicCerts.keystore)根本找不到文件——因为它在classpath里不在文件系统里。处理办法是支持一个classpath:前缀。5.2 校验License的完整代码校验服务类的实现如下import de.schlichtherle.license.DefaultLicenseParam; import de.schlichtherle.license.KeyStoreParam; import de.schlichtherle.license.LicenseContent; import de.schlichtherle.license.LicenseManager; import de.schlichtherle.license.LicenseParam; import org.springframework.core.io.ClassPathResource; import java.io.File; import java.io.FileInputStream; import java.io.InputStream; import java.util.HashMap; public class LicenseVerifyService { public LicenseContent verify(LicenseVerifyParam param) { try { // 1. 公钥库访问参数 KeyStoreParam keyStoreParam new KeyStoreParam() { Override public String getAlias() { return param.getAlias(); } Override public String getStorePwd() { return param.getStorepass(); } Override public String getKeyPwd() { return param.getKeypass(); } Override public InputStream getStream() throws java.io.IOException { return loadStream(param.getPublicKeysStorePath()); } }; // 2. 创建校验专用的LicenseManager LicenseParam licenseParam new DefaultLicenseParam( param.getSubject(), new HashMap(16), keyStoreParam); LicenseManager manager new CustomLicenseManager(licenseParam); // 3. 执行校验 return manager.verify(new File(param.getLicensePath())); } catch (Exception e) { // 校验失败记日志并返回null e.printStackTrace(); return null; } } private InputStream loadStream(String path) throws java.io.IOException { if (path.startsWith(classpath:)) { String resource path.substring(classpath:.length()); return new ClassPathResource(resource).getInputStream(); } return new FileInputStream(path); } }manager.verify(licenseFile)这一步才是校验的核心。TrueLicense会做三件事用公钥解开签名、比对内容摘要、检查时间窗口。只有三者全部通过才会返回LicenseContent对象任何一环不通过直接抛异常。所以校验返回非空就代表这份License是合法且未过期的。5.3 启动时校验 定时复验防止时间回拨很多人把License校验写进ApplicationRunner或main方法里启动时校验一次就以为万事大吉。这里有一个我实际碰到过的漏洞客户把服务器系统时间回调两三年你的过期校验就形同虚设了。解决方案很朴素启动时校验一次同时开启一个后台定时任务定期复验。import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; public class LicenseWatchdog { private final LicenseVerifyService verifyService; private final LicenseVerifyParam verifyParam; public LicenseWatchdog(LicenseVerifyService verifyService, LicenseVerifyParam verifyParam) { this.verifyService verifyService; this.verifyParam verifyParam; } public void start() { ScheduledExecutorService executor Executors.newSingleThreadScheduledExecutor(r - { Thread t new Thread(r, license-watchdog); t.setDaemon(true); return t; }); executor.scheduleAtFixedRate(() - { LicenseContent content verifyService.verify(verifyParam); if (content null) { // License失效停止核心服务、弹出提醒、限制功能…… // 按你业务的需要去处理 } }, 1, 1, TimeUnit.HOURS); } }定时复验的核心目的是防止启动时合法之后系统时间被修改这种情况。哪怕客户启动后又把时间调回2020年最迟一小时后定时任务就会再次校验并踢掉服务。另外还可以加一道相对时间防护本地记录最近一次成功校验的时间戳如果当前时间比这个时间戳还早超过5分钟说明系统时间被明显回拨过可以直接拒绝服务。这个思路实现起来很轻但能把时间篡改的路堵掉大半。5.4 读取授权信息并展示给客户校验通过时verify返回的LicenseContent对象里包含了完整的授权信息别浪费。比如产品里关于页面完全可以展示这些内容也可以做到期提醒。LicenseContent content verifyService.verify(verifyParam); if (content ! null) { // 客户名称 String holder content.getHolder(); // 签发人 String issuer content.getIssuer(); // 生效时间 java.util.Date issued content.getIssued(); // 到期时间 java.util.Date notAfter content.getNotAfter(); // 剩余天数换算 long days (notAfter.getTime() - System.currentTimeMillis()) / (1000L * 3600 * 24); // 附加信息比如客户编号 String customerNo content.getExtra().get(customerNo); // 剩余不足7天时可以做续费提醒 if (days 7) { // 提示客户联系销售续费 } }这个信息读取能力还有个实用场景当你需要排查某台机器的授权状态时不用翻日志直接把License文件放进去跑一次校验就能看到授权给了谁、什么时候过期。6. 我踩过的坑与定位思路从参数错位到环境差异6.1 Subject不一致本地好好的换项目就报错我印象最深的一个坑出现在联调阶段签发端在管理后台生成了License文件本地用一个Demo工程校验通过但真正集成进商业应用后怎么都校不过。排查链路是这样的先看异常信息报错核心关键词是subject提示授权对象不匹配于是去比对签发参数和校验参数里的subject发现一个是order-system一个是ordersystem中间多了个横线改回一致后再校验立刻通过。这个问题的隐蔽之处在于Subject并不参与加密算法你很容易以为它不重要。但它就是一把钥匙的齿形差一毫米都开不了门。我的建议很简单把Subject抽成全局静态常量签发端和校验端引用同一个常量值从源头杜绝拼写不一致。6.2 时间被改引发的授权失效与失效后复活客户环境里系统时间是不可控的我遇到过两种相反的坑第一种客户为了白嫖把系统时间调到2030年License反而一直在有效期内第二种IT部门做时钟同步服务器时间往回拨了几小时License在重启后无故失效故障单堆了一堆。前者用定时校验本地时间戳防护解决后者则需要你容忍一定的时间偏移。我的做法是本地记录上一次成功校验的时间戳只要当前时间比上一次成功校验时间早超过5分钟就判定时间异常。这样的话NTP同步导致的几秒、几分钟偏移不会被误伤而几十年的回调会被立刻识破。6.3 Keystore打包与路径问题生成端和校验端路径处理方式是不同的。生成端在管理后台运行私钥库一般是绝对路径new FileInputStream没问题。但校验端在Java应用里公钥库经常被打进jar包此时new FileInputStream(publicCerts.keystore)一定会失败。排查这个问题时我先检查了target目录下有没有公钥库文件确认在resources里存在再检查代码里的路径发现用的是文件路径而不是classpath路径。改成classpath:publicCerts.keystore并用ClassPathResource加载后问题消失。建议校验端工具类统一处理这两种路径支持classpath:前缀也支持绝对路径。这样打包成jar、war、或者外挂配置文件三种部署方式都能跑。6.4 多实例部署与机器绑定问题我最初以为License天然与机器绑定后来测试发现同一个license.lic文件放在两台不同的服务器上都能通过校验。原因在于TrueLicense默认只校验签名和有效期并不采集硬件信息。如果你的业务确实要一机一码可以在签发时把机器特征写进LicenseContent的extra里比如CPU序列号、主板序列号、磁盘序列号等校验时读取本机指纹和extra里的值做比对不一致就拒绝。需要注意的是机器指纹计算不要只依赖MAC地址因为MAC可以被修改。我一般推荐综合CPU、主板、磁盘三个维度算一个指纹串命中率更高也更难被模拟。6.5 依赖冲突与版本选择最后一个坑也是集成类库最常见的问题truelicense依赖了commons-codec、log4j等库很容易和项目已有依赖冲突导致启动报一堆ClassNotFoundException或NoSuchMethodError。排查方式很简单用Maven命令看依赖树找到冲突的传递依赖然后在pom里排除再引入项目里兼容的版本dependency groupIdde.schlichtherle.licensing/groupId artifactIdtruelicense/artifactId version1.82/version exclusions exclusion groupIdlog4j/groupId artifactIdlog4j/artifactId /exclusion /exclusions /dependency这里也顺带提一点版本选择的经验truelicense的1.x和2.x API差异比较大网上大部分资料以1.82为主。如果项目里已经有其他组件强制依赖2.x你需要参考2.x的官方文档重新适配不要盲目升版本。选定版本后把truelicense相关的API调用封装在自己模块里即便以后升级也只改封装层不动业务代码。这一点我是在一次升级踩坑之后才真正体会到的——能用一层壳解决的问题永远不要直接穿透到业务代码里。