私有化交付场景下的 License 机制有一个基本前提:软件最终运行在客户的环境里,而客户拥有那台机器上的 root、硬件和网络。这意味着我们既不能假设本地时钟可信,也不能假设程序里的校验逻辑不可绕过——只要对方有足够决心,总可以 patch 二进制。

所以对这类 License 机制要有一个合理的预期:它不可能做到「不可破解」。合理的定位是,在提供正常期限和功能授权的同时,把破解成本抬高到不再是简单修改就能绕过的程度;再配合短授权周期和顺畅续期,让未授权使用本身变得不划算。

围绕这个定位,我们在某个产品管理面控制台上做了一套离线 License 机制。下面按实现中遇到的主要问题展开:

  • 公钥如何嵌入客户侧二进制
  • 硬件绑定与 Kubernetes(k8s)容器里的权限问题
  • 用锚点加单调时钟防御时钟回拨
  • garble 二进制符号混淆
  • 真实攻击路径与安全边界

1. 把公钥嵌入客户侧二进制

离线 License 的验证链路很朴素:程序里嵌一个公钥,启动后读取客户现场的证书文件,用公钥验签,再按证书里的授权内容决定哪些功能可用。难点在第一步——公钥必须跟着二进制一起发到客户现场,而那个现场对客户是完全开放的。

换句话说,我们要把一把验证钥匙放进对方可以任意分析的机器里。这把钥匙本身不需要保密(公钥本来就可以公开),但如果它太容易被找到、被替换,整个授权机制就会失效。

1.1 私钥、证书、公钥各自的角色

这套机制里的密钥材料只有三种:

  • 私钥:放在签发机器上,是唯一需要保密的东西;
  • 证书:用私钥签出来的授权文件,交给客户部署;
  • 公钥:嵌在客户运行的程序里,只负责验证证书签名。

在我们的设计中,证书本身是一行文本:<base64url(payload)>.<base64url(signature)>。其中 payload 是 JSON 格式,承载客户名、到期时间、模块清单、配额、硬件绑定等授权内容;signature 则是对 payload 字节做 RSA-SHA256 签名后的结果。我们选用了 JSON 作为 payload 格式,主要是为了扩展方便,但它并不是唯一选择——固定长度字段同样是常见做法,还能避免引入 JSON 解析库、减小证书体积。

签名和验签基于 RSA 的非对称运算。签发时,先用 SHA-256 等哈希算法计算 payload 的摘要,再用私钥对这个摘要做「加密」运算,得到 signature。验签时,用公钥对 signature 做「解密」运算,把结果与 payload 的哈希摘要比对。只有持有私钥的签发方才能生成有效的 signature,而任何人都可以用公开的公钥验证它。证书在传输或部署过程中如果被篡改,摘要就会对不上,验签随之失败。

程序启动时和运行过程中会周期性读取证书,用内嵌公钥对证书验签并检查有效期限,通过后就按 payload 内容放行对应功能。

1.2 公钥如何进入程序

公钥进入程序通常有三种做法。

硬编码到源码常量:把公钥直接写进源码。

1
2
3
4
5
// 字符串形式
const PublicKey = "-----BEGIN PUBLIC KEY-----\nMIIB..."

// 或字节切片形式
var PublicKey = []byte{0x30, 0x82, ...}

这是最简单的方式,但公钥以可识别形态存在于源码和二进制中,客户用 strings 命令就能定位并替换。另外,源码里一旦固定生产公钥,本地开发同事就必须持有由权威私钥签发的开发证书才能跑通流程,整个团队会被绑定到私钥持有者身上。公钥的存放方式在 1.4 节讨论。

编译期通过 -ldflags 注入:源码里留空变量,构建时再写入公钥值。

1
2
# 源码中先声明空变量:var PublicKey string
garble -literals build -ldflags="-X main.PublicKey=REAL_PUBLIC_KEY_VALUE"

这样源码里不直接出现公钥,但注入的值仍是字符串常量。配合 garble 混淆时,garble 的 -literals 不会处理通过 -ldflags -X 写入的字面量,公钥值会以明文留在二进制里(验证过程见第 4 节)。因此这条路在工程上不够干净,不是我们最终选择。

用 go:embed 嵌入:通过 //go:embed 编译指令把公钥文件编译进二进制。

1
2
//go:embed public_key.pem // public_key.pem 被 git 忽略
var PublicKey []byte

public_key.pem 放在被 git 忽略的文件里,公钥就不在源码中,而是通过文件进入编译流程;加载路径也和本地开发、CI 保持一致。这是我们最终采用的方式。

需要说明的是,garble 能正常编译 embed,但不会混淆 embed 文件的字节内容(验证见第 4 节)。所以从「内容是否被隐藏」的角度看,go:embed 并不比 -ldflags 更先进,两者都需要额外方式来保护内容(比如可逆变换)。go:embed 的优势在于分离更干净:公钥是一个独立文件,CI/CD 可以在构建前对它做清晰的预处理(变换、转换、注入),本地开发也更容易用临时文件替换,而不会被编译命令的复杂度牵制。

1.3 基于 go:embed 的进一步设计

选定 go:embed 后,还有两个直接影响实现的问题:如何防止公钥被直接替换,以及 embed 文件在代码仓库里如何组织。

可逆变换,而不是明文嵌入go:embed 把文件内容作为连续数据直接嵌入二进制,这块数据在二进制里有可定位的边界,且 garble 不会混淆 embed 文件的内容(见第 4 节)。也就是说,直接嵌入的公钥会以明文形式躺在二进制里一个找得到的位置。

因此我们在 embed 文件里存的不是 PEM 明文,而是公钥 PKIX DER 字节经过某种可逆变换后的形态,让它不再以可识别的 ASN.1 结构明文出现。客户即便用 strings 命令搜索二进制,也定位不到可以直接替换的公钥内容;想绕过这层变换需要额外投入,替换成本从直接替换变成了需要专门处理。

我们的设计判断是:不指望彻底隐藏这块数据的边界,而是把门槛放在变换过程上。攻击者即便定位到这块数据区域,拿到的也不是 ASN.1 公钥,而是一组需要还原掩码才能使用的字节;想替换公钥,就得先还原掩码、再重新生成变换后的文件并重新编译。

变换所用的 XOR 掩码以字面量形式写在源码里,由 garble 的字面量混淆保护,静态搜索拿不到原始掩码序列(验证见第 4 节);程序结构的隐藏同样交给 garble。但这只解决了静态分析。程序运行时必须还原掩码才能做 XOR,动态调试、内存 dump 或者直接 patch 校验逻辑仍然是可行的。我们的安全边界没有因此改变:整套机制始终定位为「抬高绕过成本」,而不是「防住拥有 root 权限的刻意攻击」。

独立的 embed 叶子包:在我们的代码结构里,签发工具负责生成 embed 文件,验签代码负责读取它。签发工具本身属于 license 包的一部分,如果 //go:embed 声明也放在 license 包里,就会出现循环依赖:编译 license 包需要先拿到 embed 文件,而生成 embed 文件又需要先编译 license 包里的签发工具。这就是「生成工具依赖自己要生成的文件」的死锁,无法通过编译。把它独立成一个叶子包后,签发工具只依赖变换函数,只有验签侧依赖文件本身,循环被打破。而且文件缺失时 go:embed 会直接编译报错,不会出现「漏注入而编出无钥二进制」的隐患。

1.4 公钥的存放位置

公钥无需保密,但放在哪里会直接影响开发体验和泄露面。我们最关心的问题是:后续同事做日常代码开发时,如何不与权威密钥文件强绑定。围绕这个问题,我们评估了三种做法。

做法一:代码仓库里不放任何密钥或证书,本地开发完全自签,CI/CD 从环境变量读取权威公钥。 本地脚本在启动服务前自动生成一次性 dev 密钥对和自签证书,开发者无感知;CI/CD 构建时则从 GitHub Actions / GitLab CI Variables 等环境变量里读取权威公钥,生成 embed 文件并打包。优点是仓库最干净,没有任何密钥文件;权威私钥自始至终只存在于签发机器,不会进入仓库或 CI/CD;测试走临时钥匙对,生产包直接嵌入权威公钥,两者走同一条加载路径。代价是需要 CI/CD 支持读取环境变量里的公钥,本地开发也需要自签脚本兜底。

做法二:公钥和开发证书都提交进仓库,权威私钥离线保存。 公钥本身就是设计为可公开的,开发证书则让同事本地直接能跑,不用关心密钥。优点是对开发同事最友好,CI/CD 也无需改造。缺点是开发证书有有效期,需要定期用权威私钥重新签发并更新到仓库。更大的隐患是仓库里同时放着公钥和这张证书:证书一旦外流,就能在任何嵌了同一把公钥的程序上通过验签,风险更高。

做法三:公钥提交进仓库,但不提供开发证书,每位同事本地自签。 这样不用担心开发证书泄漏,也不需要改动 CI/CD。但每个人本地生成的自签公钥和证书容易污染仓库;一旦误提交,会让 CI/CD 打包进非预期的公钥,影响成品。虽然危害可控,但清理起来麻烦。

我们最终选择了做法一。它的核心优势是把权威私钥的暴露面降到最低:私钥不进入仓库,不进入 CI/CD,只有签发机器持有;同时生产包在 CI/CD 里直接嵌入权威公钥,不需要后续再换密钥或重新签名。开发和测试用的临时密钥对全部被 git 忽略,即使误提交也不会影响生产镜像。

2. 硬件绑定:Kubernetes(k8s)容器里的权限问题

证书除了限定时间和模块,还可以做硬件绑定:payload 里带一组硬件指纹的 SHA-256,运行时采集当前机器的指纹并比对,命中数低于阈值就认为绑定不匹配。这相当于把证书和某台物理机绑定,防止客户把部署目录整体复制到另一台机器上继续使用。

我们的控制台本身运行在 k8s 里,主容器是一个普通 Pod,通过读取多个集群的 kubeconfig 来纳管这些集群。要读取机器 UUID、MAC 地址、主板序列号、磁盘序列号这类 sysfs 信息,通常需要 root 权限;但主容器又是持有全部集群 kubeconfig 的组件,权限越大风险越大。这里出现了一个矛盾:要做硬件绑定,就得提权;提了权,又会让关键组件的暴露面变大

我们的处理方式是把提权需求限制在一个短生命周期的 initContainer:它以 root 运行,只读挂载宿主机的 /sys,把采集结果写进与主容器共享的临时卷,然后退出。主容器维持原有安全基线不变,启动时从临时卷读取指纹文件做绑定校验。真正需要特权的代码只运行很短一段时间,而且不接触网络或 kubeconfig。

采集过程遵循两个原则。第一,原始序列号不离开采集进程——每个值就地哈希成 SHA-256 再写出,输出的 JSON 可以安全地跨环境传输。实施人员把采集结果带回签发方、前端页面展示当前环境指纹,看到的都只是哈希。第二,主容器权限不作任何放大,保持原来的安全基线。

匹配规则采用阈值制:N 个指纹中至少命中指定个数即通过。这样客户换一块网卡之类的单点硬件更换不会导致证书失效;整机更换则走重新签发流程。在虚拟化环境里,UUID、MAC 地址、磁盘序列号都可以被 hypervisor 控制,所以硬件指纹只能防「把部署目录整体复制到另一台机器」这种低成本复制;如果 hypervisor 层可以人为操控、连硬件标识一起克隆,这种场景就无法防御。它的价值是提高克隆或错误部署的成本,与整体威胁模型定位一致。

采集和运行时比对用的是同一份二进制,随主镜像一起分发。签发证书前在目标节点上运行它采集硬件指纹,运行时 initContainer 里运行的也是它。同一份代码算出来的哈希自然一致。

3. 防御时钟回拨:锚点加单调时钟

离线产品没有远程心跳,证书有效期只能靠本地时间判定。而本地时间客户可控,这是整个机制里脆弱的地方。这里需要先区分两个时间概念:

  • 墙上时钟(wall clock):系统显示的时间,可以被随意修改;
  • 单调时钟(monotonic clock):进程启动后真实流逝的时间,不受墙上时钟调整的影响。

3.1 只记录 last seen 的问题

第一种思路是持久化一个「last seen」时间戳,每次检查时取 max(墙上时钟, last seen) 作为有效时间并落盘。这能挡住「过期之后把时钟拨回有效期窗口」的情况:last seen 已经越过到期点,再往回拨也不会生效。

但它漏掉了另一种情况:在过期前回拨并冻结时钟。假设证书在 8 月 25 日到期,客户在 8 月 20 日把系统时间拨回 8 月 1 日并保持不动。此后每次检查墙上时钟都是 8 月 1 日,始终小于 last seen(8 月 20 日)。于是 max 函数的结果冻结在 8 月 20 日不再推进,到期时刻始终等不到,证书实际上不会过期。

这个方案只防得住「过期后回拨」,防不住「过期前冻结」。

3.2 锚点加单调时钟

修复后的有效时间定义为:

1
有效时间 = max(墙上时钟, 锚点 + 单调时钟流逝)

锚点(即 last seen 时间戳)持久化在一个独立文件里,单调时钟对应进程运行时长。墙上时钟正常时,有效时间跟随墙上时钟,锚点同步推进并落盘;墙上时钟被回拨或冻结时,有效时间从锚点起按进程真实运行时长继续走,到期后仍然拒绝服务。重启后从落盘锚点接着累计。

「冻结时钟」「删锚点文件再冻结」「冻结加反复重启」这些操作,每次最多让有效时间从某个更近的点继续走;只要外部干预停止,锚点就会稳定向前推进,不会回退。这意味着绕过者必须持续干预,一次性操作无法永久生效,从而提高回拨成本。

时间防御逻辑因此集中在一处:证书拥有自己的「固有时钟」,不散落在各个业务判断点。

1
2
3
4
5
6
// 证书状态只依赖于「自己的有效时间」,
// 而不是直接读取墙上时钟或单调时钟。
func (c *Checker) State() Status {
    now := effectiveNow(wallClockNow()) // 算出证书自己的有效时间
    return currentLicense.StatusAt(now) // 再查该时间点的状态
}

Go 的 time.Time 内部同时保存墙上时钟读数和单调读数,time.Now() 返回的时间点也携带两者。当两个 time.Time 都带有单调读数时,time.Since(start) 优先用单调读数相减,得到的是进程真实运行时长,而不是墙上时钟的差值:

1
2
3
4
start := time.Now()
// 客户通过 settimeofday 把墙上时钟往回拨一年
elapsed := time.Since(start)
// elapsed 仍然是进程启动后真实流逝的时间,不受墙上时钟回拨影响

但单调读数不能持久化:进程重启会归零,格式化或序列化也会丢掉。所以锚点用墙上时钟落盘,单调部分只存于内存,两者各司其职。

我们还希望锚点文件的语义不要过于明显。如果文件名或内容带有明显语义,查看目录的人就会知道「这是回拨防御状态,删了它就能重置」。所以它被存成 8 字节小端 uint64 裸二进制,不带字段名、不是 JSON、文件名也不带语义。

3.3 安全边界

这层防御不承诺防 root。有 root 权限的客户可以直接删除锚点文件或 patch 二进制删掉时间判定,那才是真正的边界。删除之外的另一种操作是备份早期锚点文件、之后再配合冻结时钟恢复它,让有效时间回退到备份点;但这类操作和「删锚点文件再冻结」一样需要持续干预,无法一次操作永久生效,不改变整体的防御定位。我们同时用二进制混淆把校验逻辑和锚点机制隐藏起来(见第 4 节),把绕过的成本从直接删除文件抬高到需要认真逆向;交付流程则负责控制证书授权周期和续期通道。

另外两个小边界是:进程停止期间时间不走,因为服务本就不在提供;Linux 的 CLOCK_MONOTONIC 不计系统 suspend 时长,VM 挂起期间时间暂停。

3.4 测试策略:把两个时钟都注入

这套机制的正确性很大程度上集中在时间判定上,而时间测试最容易写成 time.Sleep 式的脆弱用例。我们的做法是把墙上时钟和单调时钟都做成依赖注入:在代码里提供两个可替换的时钟源,一个返回当前 wall time,一个返回当前 uptime。测试时把它们换成手动推进的假时钟,所有有效期判定都走这两个注入源,不再依赖真实时间。

几类原本难以构造的场景,由此可以写成普通单测:墙上时钟冻结在过期前、单调时钟推 30 天,断言证书 expired;冻结期间锚点照常落盘,重新构造校验器模拟重启,从落盘锚点继续走,再推几天,断言 expired,验证「冻结加反复重启」无法重置时间;宽限期边界前后 1 秒的状态迁移、回拨超容忍度的告警,也都是表驱动断言。

4. garble 二进制符号混淆

前面几节多次提到 garble,这一节集中说明它的原理、能力边界,以及围绕它做的几个验证。

garble 的原理是包装 Go 工具链的 toolexec,在编译期替换标识符:包路径、类型名、函数名、变量名全部被替换成无意义的哈希串,同时裁剪符号表与调试信息。开启 -literals 后,连字符串和数字字面量也会被混淆,运行时才还原。最终构建产物里既没有可读的调用栈,也没有能直接定位 license 校验逻辑的符号名。

4.1 -literals 混淆什么、不混淆什么

garble 的 -literals 模式会对源码中的字符串和字节字面量做 AST 级变换,把字面量替换成运行时现场解密的匿名函数,使用 XOR/ADD/SUB、随机 key、swap、split、shuffle、seed 等策略,并通过 external keys 和 proxy dispatcher 把值藏进随机嵌套 struct。这套机制会让字面量在二进制里以碎片形式出现,加上 junk 字节和符号混淆,单个字面量的边界并不突出。

源码里的普通字面量在它的保护范围内。我们以 XOR 掩码做过验证:32 字节的掩码以 var keyMask = []byte{...} 形式写在源码里,普通构建的二进制里能直接搜到连续字节,但经 garble -literals 混淆后,原始掩码序列在二进制里无法直接搜到,静态 strings 或十六进制搜索都拿不到。

-literals 只覆盖「源码里的字面量」,有两类数据不在它的处理范围内,我们同样做过验证。

-ldflags -X 注入的值不会被混淆。 用 garble v0.17.0 + Go 1.26.5 验证:第 1 节的注入命令产出的二进制中,REAL_PUBLIC_KEY_VALUE 仍能被 strings 找到,而源码中硬编码的普通常量在同一次构建里是被混淆的。另一个问题是 garble 会重命名标识符,-X 指定的包路径和变量名需要 garble 能正确解析。garble 仓库的 issue #717 跟踪的正是这个问题:-literals-ldflags 配合时注入值未被混淆,至今未修复(README 中「-literals 也会替换 -X 注入的字符串」的说法与此不符,属于过时信息);#820 则显示 ldflags 在 garble 测试套件中曾触发 ABI 相关失败。

go:embed 嵌入的文件字节不会被混淆。 garble 能正常编译 embed,但经实测,嵌入的明文会原样出现在二进制里。embed 数据在二进制里是一个连续的 blob,前面跟着 Go runtime 生成的 slice header,garble 不会处理这个 header,也不会拆分或伪装这块数据。这正是第 1 节选择对公钥做可逆变换、而不是直接嵌入明文的原因。

4.2 与可逆变换的分工

可逆变换和 garble 的分工不同:可逆变换藏的是内容,让公钥不以可识别结构明文存在;garble 藏的是结构,让校验逻辑的位置和调用关系不可读。它们都不构成密码学边界,但合力把简单修改的成本抬高到了需要逆向分析的程度。

5. 真实攻击路径与安全边界

按攻击成本从低到高,可以回顾这套设计挡住了什么、没挡住什么。

最低成本的攻击——用 strings 命令搜索二进制找到 PEM 公钥,替换后自签证书——已经被挡住。公钥不是 PEM 明文,而是经过可逆变换的 DER 数据;掩码本身也被 garble 字面量混淆,静态搜索拿不到。这意味着简单的静态替换不再奏效。

再往上走,攻击者可以定位到 embed 的边界(slice header 里的长度和指针),确认这里有一块变换后的数据。但想还原出公钥,仍然需要拿到 XOR 掩码。静态逆向 garble 的字面量混淆可以恢复掩码,成本明显高于字符串替换;动态调试则能在运行内存里直接读出还原后的掩码。

更实际的攻击路径会绕开公钥和掩码本身:直接 patch 二进制让验签入口永远放行,或者让掩码还原步骤原样返回输入。有 root 权限的客户也可以删除锚点文件重置时间防御。这些场景不在设计目标之内。

所以这套机制的真实定位是:挡住无预谋的静态替换和误用,把有预谋的绕过成本抬高到需要逆向、调试或重新编译;最终保障仍然交给短授权周期和顺畅续期。它不是密码学边界,而是一道工程门槛。

结语

整套设计的基本取舍是如实描述能力边界:承认本地没有可信时钟和不可跳过的校验,把工程投入放在抬高轻易绕过的成本上——可逆变换、混淆、落盘文件的去语义化、锚点推进——同时把商业保障交给短授权周期和顺畅续期,比如 UI 上传即生效、无需重启、无需 SSH 上节点。

私有化 License 机制无法做到绝对安全,但可以让正常客户不会误用,让未授权使用不再零成本。