<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>license on Chaney&#39;s MoonBook</title>
    <link>https://chaneyzorn.github.io/tags/license/</link>
    <description>Recent content in license on Chaney&#39;s MoonBook</description>
    <follow_challenge>
        <feedId>94402801171278848</feedId>
        <userId>60932538427591680</userId>
    </follow_challenge>
    <image>
      <title>Chaney&#39;s MoonBook</title>
      <url>https://chaneyzorn.github.io/cm/chaney-cover.jpg</url>
      <link>https://chaneyzorn.github.io/cm/chaney-cover.jpg</link>
    </image>
    <generator>Hugo(0.160.1) -- gohugo.io</generator>
    <language>zh</language>
    <managingEditor>chaneyzorn#gmail#com (ChaneyZorn)</managingEditor>
    <webMaster>chaneyzorn#gmail#com (ChaneyZorn)</webMaster>
    <copyright>Copyright © ChaneyZorn | CC BY-NC-ND 4.0 |</copyright>
    <lastBuildDate>Sun, 23 Aug 2026 21:53:49 +0800</lastBuildDate>
    <atom:link href="https://chaneyzorn.github.io/tags/license/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>私有化交付的 License 机制设计</title>
      <link>https://chaneyzorn.github.io/codes/private-license-design/</link>
      <pubDate>Sun, 23 Aug 2026 21:53:49 +0800</pubDate><author>chaneyzorn#gmail#com (ChaneyZorn)</author>
      <guid>https://chaneyzorn.github.io/codes/private-license-design/</guid>
      <description><![CDATA[<p>私有化交付场景下的 License 机制有一个基本前提：软件最终运行在客户的环境里，而客户拥有那台机器上的 root、硬件和网络。这意味着我们既不能假设本地时钟可信，也不能假设程序里的校验逻辑不可绕过——只要对方有足够决心，总可以 patch 二进制。</p>
<p>所以对这类 License 机制要有一个合理的预期：它不可能做到「不可破解」。合理的定位是，在提供正常期限和功能授权的同时，把破解成本抬高到不再是简单修改就能绕过的程度；再配合短授权周期和顺畅续期，让未授权使用本身变得不划算。</p>
<p>围绕这个定位，我们在某个产品管理面控制台上做了一套离线 License 机制。下面按实现中遇到的主要问题展开：</p>
<ul>
<li>公钥如何嵌入客户侧二进制</li>
<li>硬件绑定与 Kubernetes（k8s）容器里的权限问题</li>
<li>用锚点加单调时钟防御时钟回拨</li>
<li>garble 二进制符号混淆</li>
<li>真实攻击路径与安全边界</li>
</ul>
<h2 id="1-把公钥嵌入客户侧二进制">1. 把公钥嵌入客户侧二进制</h2>
<p>离线 License 的验证链路很朴素：程序里嵌一个公钥，启动后读取客户现场的证书文件，用公钥验签，再按证书里的授权内容决定哪些功能可用。难点在第一步——公钥必须跟着二进制一起发到客户现场，而那个现场对客户是完全开放的。</p>
<p>换句话说，我们要把一把验证钥匙放进对方可以任意分析的机器里。这把钥匙本身不需要保密（公钥本来就可以公开），但如果它太容易被找到、被替换，整个授权机制就会失效。</p>
<h3 id="11-私钥证书公钥各自的角色">1.1 私钥、证书、公钥各自的角色</h3>
<p>这套机制里的密钥材料只有三种：</p>
<ul>
<li><strong>私钥</strong>：放在签发机器上，是唯一需要保密的东西；</li>
<li><strong>证书</strong>：用私钥签出来的授权文件，交给客户部署；</li>
<li><strong>公钥</strong>：嵌在客户运行的程序里，只负责验证证书签名。</li>
</ul>
<p>在我们的设计中，证书本身是一行文本：<code>&lt;base64url(payload)&gt;.&lt;base64url(signature)&gt;</code>。其中 payload 是 JSON 格式，承载客户名、到期时间、模块清单、配额、硬件绑定等授权内容；signature 则是对 payload 字节做 RSA-SHA256 签名后的结果。我们选用了 JSON 作为 payload 格式，主要是为了扩展方便，但它并不是唯一选择——固定长度字段同样是常见做法，还能避免引入 JSON 解析库、减小证书体积。</p>
<p>签名和验签基于 RSA 的非对称运算。签发时，先用 SHA-256 等哈希算法计算 payload 的摘要，再用私钥对这个摘要做「加密」运算，得到 signature。验签时，用公钥对 signature 做「解密」运算，把结果与 payload 的哈希摘要比对。只有持有私钥的签发方才能生成有效的 signature，而任何人都可以用公开的公钥验证它。证书在传输或部署过程中如果被篡改，摘要就会对不上，验签随之失败。</p>
<p>程序启动时和运行过程中会周期性读取证书，用内嵌公钥对证书验签并检查有效期限，通过后就按 payload 内容放行对应功能。</p>
<h3 id="12-公钥如何进入程序">1.2 公钥如何进入程序</h3>
<p>公钥进入程序通常有三种做法。</p>
<p><strong>硬编码到源码常量</strong>：把公钥直接写进源码。</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span><span class="lnt">3
</span><span class="lnt">4
</span><span class="lnt">5
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="cl"><span class="c1">// 字符串形式</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="kd">const</span><span class="w"> </span><span class="nx">PublicKey</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="s">&#34;-----BEGIN PUBLIC KEY-----\nMIIB...&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="c1">// 或字节切片形式</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="kd">var</span><span class="w"> </span><span class="nx">PublicKey</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="p">[]</span><span class="kt">byte</span><span class="p">{</span><span class="mh">0x30</span><span class="p">,</span><span class="w"> </span><span class="mh">0x82</span><span class="p">,</span><span class="w"> </span><span class="o">...</span><span class="p">}</span><span class="w">
</span></span></span></code></pre></td></tr></table>
</div>
</div><p>这是最简单的方式，但公钥以可识别形态存在于源码和二进制中，客户用 <code>strings</code> 命令就能定位并替换。另外，源码里一旦固定生产公钥，本地开发同事就必须持有由权威私钥签发的开发证书才能跑通流程，整个团队会被绑定到私钥持有者身上。公钥的存放方式在 1.4 节讨论。</p>
<p><strong>编译期通过 -ldflags 注入</strong>：源码里留空变量，构建时再写入公钥值。</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl"><span class="c1"># 源码中先声明空变量：var PublicKey string</span>
</span></span><span class="line"><span class="cl">garble -literals build -ldflags<span class="o">=</span><span class="s2">&#34;-X main.PublicKey=REAL_PUBLIC_KEY_VALUE&#34;</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>这样源码里不直接出现公钥，但注入的值仍是字符串常量。配合 garble 混淆时，garble 的 <code>-literals</code> 不会处理通过 <code>-ldflags -X</code> 写入的字面量，公钥值会以明文留在二进制里（验证过程见第 4 节）。因此这条路在工程上不够干净，不是我们最终选择。</p>
<p><strong>用 go:embed 嵌入</strong>：通过 <code>//go:embed</code> 编译指令把公钥文件编译进二进制。</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="cl"><span class="cp">//go:embed public_key.pem // public_key.pem 被 git 忽略</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="kd">var</span><span class="w"> </span><span class="nx">PublicKey</span><span class="w"> </span><span class="p">[]</span><span class="kt">byte</span><span class="w">
</span></span></span></code></pre></td></tr></table>
</div>
</div><p>把 <code>public_key.pem</code> 放在被 git 忽略的文件里，公钥就不在源码中，而是通过文件进入编译流程；加载路径也和本地开发、CI 保持一致。这是我们最终采用的方式。</p>
<p>需要说明的是，garble 能正常编译 embed，但不会混淆 embed 文件的字节内容（验证见第 4 节）。所以从「内容是否被隐藏」的角度看，go:embed 并不比 <code>-ldflags</code> 更先进，两者都需要额外方式来保护内容（比如可逆变换）。go:embed 的优势在于分离更干净：公钥是一个独立文件，CI/CD 可以在构建前对它做清晰的预处理（变换、转换、注入），本地开发也更容易用临时文件替换，而不会被编译命令的复杂度牵制。</p>
<h3 id="13-基于-goembed-的进一步设计">1.3 基于 go:embed 的进一步设计</h3>
<p>选定 <code>go:embed</code> 后，还有两个直接影响实现的问题：如何防止公钥被直接替换，以及 embed 文件在代码仓库里如何组织。</p>
<p><strong>可逆变换，而不是明文嵌入</strong>：<code>go:embed</code> 把文件内容作为连续数据直接嵌入二进制，这块数据在二进制里有可定位的边界，且 garble 不会混淆 embed 文件的内容（见第 4 节）。也就是说，直接嵌入的公钥会以明文形式躺在二进制里一个找得到的位置。</p>
<p>因此我们在 embed 文件里存的不是 PEM 明文，而是公钥 PKIX DER 字节经过某种可逆变换后的形态，让它不再以可识别的 ASN.1 结构明文出现。客户即便用 <code>strings</code> 命令搜索二进制，也定位不到可以直接替换的公钥内容；想绕过这层变换需要额外投入，替换成本从直接替换变成了需要专门处理。</p>
<p>我们的设计判断是：不指望彻底隐藏这块数据的边界，而是把门槛放在变换过程上。攻击者即便定位到这块数据区域，拿到的也不是 ASN.1 公钥，而是一组需要还原掩码才能使用的字节；想替换公钥，就得先还原掩码、再重新生成变换后的文件并重新编译。</p>
<p>变换所用的 XOR 掩码以字面量形式写在源码里，由 garble 的字面量混淆保护，静态搜索拿不到原始掩码序列（验证见第 4 节）；程序结构的隐藏同样交给 garble。但这只解决了静态分析。程序运行时必须还原掩码才能做 XOR，动态调试、内存 dump 或者直接 patch 校验逻辑仍然是可行的。我们的安全边界没有因此改变：整套机制始终定位为「抬高绕过成本」，而不是「防住拥有 root 权限的刻意攻击」。</p>
<p><strong>独立的 embed 叶子包</strong>：在我们的代码结构里，签发工具负责生成 embed 文件，验签代码负责读取它。签发工具本身属于 license 包的一部分，如果 <code>//go:embed</code> 声明也放在 license 包里，就会出现循环依赖：编译 license 包需要先拿到 embed 文件，而生成 embed 文件又需要先编译 license 包里的签发工具。这就是「生成工具依赖自己要生成的文件」的死锁，无法通过编译。把它独立成一个叶子包后，签发工具只依赖变换函数，只有验签侧依赖文件本身，循环被打破。而且文件缺失时 <code>go:embed</code> 会直接编译报错，不会出现「漏注入而编出无钥二进制」的隐患。</p>
<h3 id="14-公钥的存放位置">1.4 公钥的存放位置</h3>
<p>公钥无需保密，但放在哪里会直接影响开发体验和泄露面。我们最关心的问题是：<strong>后续同事做日常代码开发时，如何不与权威密钥文件强绑定</strong>。围绕这个问题，我们评估了三种做法。</p>
<p><strong>做法一：代码仓库里不放任何密钥或证书，本地开发完全自签，CI/CD 从环境变量读取权威公钥。</strong> 本地脚本在启动服务前自动生成一次性 dev 密钥对和自签证书，开发者无感知；CI/CD 构建时则从 GitHub Actions / GitLab CI Variables 等环境变量里读取权威公钥，生成 embed 文件并打包。优点是仓库最干净，没有任何密钥文件；权威私钥自始至终只存在于签发机器，不会进入仓库或 CI/CD；测试走临时钥匙对，生产包直接嵌入权威公钥，两者走同一条加载路径。代价是需要 CI/CD 支持读取环境变量里的公钥，本地开发也需要自签脚本兜底。</p>
<p><strong>做法二：公钥和开发证书都提交进仓库，权威私钥离线保存。</strong> 公钥本身就是设计为可公开的，开发证书则让同事本地直接能跑，不用关心密钥。优点是对开发同事最友好，CI/CD 也无需改造。缺点是开发证书有有效期，需要定期用权威私钥重新签发并更新到仓库。更大的隐患是仓库里同时放着公钥和这张证书：证书一旦外流，就能在任何嵌了同一把公钥的程序上通过验签，风险更高。</p>
<p><strong>做法三：公钥提交进仓库，但不提供开发证书，每位同事本地自签。</strong> 这样不用担心开发证书泄漏，也不需要改动 CI/CD。但每个人本地生成的自签公钥和证书容易污染仓库；一旦误提交，会让 CI/CD 打包进非预期的公钥，影响成品。虽然危害可控，但清理起来麻烦。</p>
<p>我们最终选择了做法一。它的核心优势是<strong>把权威私钥的暴露面降到最低</strong>：私钥不进入仓库，不进入 CI/CD，只有签发机器持有；同时生产包在 CI/CD 里直接嵌入权威公钥，不需要后续再换密钥或重新签名。开发和测试用的临时密钥对全部被 git 忽略，即使误提交也不会影响生产镜像。</p>
<h2 id="2-硬件绑定kubernetesk8s容器里的权限问题">2. 硬件绑定：Kubernetes（k8s）容器里的权限问题</h2>
<p>证书除了限定时间和模块，还可以做硬件绑定：payload 里带一组硬件指纹的 SHA-256，运行时采集当前机器的指纹并比对，命中数低于阈值就认为绑定不匹配。这相当于把证书和某台物理机绑定，防止客户把部署目录整体复制到另一台机器上继续使用。</p>
<p>我们的控制台本身运行在 k8s 里，主容器是一个普通 Pod，通过读取多个集群的 kubeconfig 来纳管这些集群。要读取机器 UUID、MAC 地址、主板序列号、磁盘序列号这类 sysfs 信息，通常需要 root 权限；但主容器又是持有全部集群 kubeconfig 的组件，权限越大风险越大。这里出现了一个矛盾：<strong>要做硬件绑定，就得提权；提了权，又会让关键组件的暴露面变大</strong>。</p>
<p>我们的处理方式是把提权需求限制在一个短生命周期的 <strong>initContainer</strong>：它以 root 运行，只读挂载宿主机的 <code>/sys</code>，把采集结果写进与主容器共享的临时卷，然后退出。主容器维持原有安全基线不变，启动时从临时卷读取指纹文件做绑定校验。真正需要特权的代码只运行很短一段时间，而且不接触网络或 kubeconfig。</p>
<p>采集过程遵循两个原则。第一，原始序列号不离开采集进程——每个值就地哈希成 SHA-256 再写出，输出的 JSON 可以安全地跨环境传输。实施人员把采集结果带回签发方、前端页面展示当前环境指纹，看到的都只是哈希。第二，主容器权限不作任何放大，保持原来的安全基线。</p>
<p>匹配规则采用阈值制：N 个指纹中至少命中指定个数即通过。这样客户换一块网卡之类的单点硬件更换不会导致证书失效；整机更换则走重新签发流程。在虚拟化环境里，UUID、MAC 地址、磁盘序列号都可以被 hypervisor 控制，所以硬件指纹只能防「把部署目录整体复制到另一台机器」这种低成本复制；如果 hypervisor 层可以人为操控、连硬件标识一起克隆，这种场景就无法防御。它的价值是提高克隆或错误部署的成本，与整体威胁模型定位一致。</p>
<p>采集和运行时比对用的是同一份二进制，随主镜像一起分发。签发证书前在目标节点上运行它采集硬件指纹，运行时 initContainer 里运行的也是它。同一份代码算出来的哈希自然一致。</p>
<h2 id="3-防御时钟回拨锚点加单调时钟">3. 防御时钟回拨：锚点加单调时钟</h2>
<p>离线产品没有远程心跳，证书有效期只能靠本地时间判定。而本地时间客户可控，这是整个机制里脆弱的地方。这里需要先区分两个时间概念：</p>
<ul>
<li><strong>墙上时钟（wall clock）</strong>：系统显示的时间，可以被随意修改；</li>
<li><strong>单调时钟（monotonic clock）</strong>：进程启动后真实流逝的时间，不受墙上时钟调整的影响。</li>
</ul>
<h3 id="31-只记录-last-seen-的问题">3.1 只记录 last seen 的问题</h3>
<p>第一种思路是持久化一个「last seen」时间戳，每次检查时取 <code>max(墙上时钟, last seen)</code> 作为有效时间并落盘。这能挡住「过期之后把时钟拨回有效期窗口」的情况：last seen 已经越过到期点，再往回拨也不会生效。</p>
<p>但它漏掉了另一种情况：<strong>在过期前回拨并冻结时钟</strong>。假设证书在 8 月 25 日到期，客户在 8 月 20 日把系统时间拨回 8 月 1 日并保持不动。此后每次检查墙上时钟都是 8 月 1 日，始终小于 last seen（8 月 20 日）。于是 max 函数的结果冻结在 8 月 20 日不再推进，到期时刻始终等不到，证书实际上不会过期。</p>
<p>这个方案只防得住「过期后回拨」，防不住「过期前冻结」。</p>
<h3 id="32-锚点加单调时钟">3.2 锚点加单调时钟</h3>
<p>修复后的有效时间定义为：</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">有效时间 = max(墙上时钟, 锚点 + 单调时钟流逝)
</span></span></code></pre></td></tr></table>
</div>
</div><p>锚点（即 last seen 时间戳）持久化在一个独立文件里，单调时钟对应进程运行时长。墙上时钟正常时，有效时间跟随墙上时钟，锚点同步推进并落盘；墙上时钟被回拨或冻结时，有效时间从锚点起按进程真实运行时长继续走，到期后仍然拒绝服务。重启后从落盘锚点接着累计。</p>
<p>「冻结时钟」「删锚点文件再冻结」「冻结加反复重启」这些操作，每次最多让有效时间从某个更近的点继续走；只要外部干预停止，锚点就会稳定向前推进，不会回退。这意味着绕过者必须持续干预，一次性操作无法永久生效，从而提高回拨成本。</p>
<p>时间防御逻辑因此集中在一处：<strong>证书拥有自己的「固有时钟」</strong>，不散落在各个业务判断点。</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span><span class="lnt">3
</span><span class="lnt">4
</span><span class="lnt">5
</span><span class="lnt">6
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="cl"><span class="c1">// 证书状态只依赖于「自己的有效时间」，</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="c1">// 而不是直接读取墙上时钟或单调时钟。</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="kd">func</span><span class="w"> </span><span class="p">(</span><span class="nx">c</span><span class="w"> </span><span class="o">*</span><span class="nx">Checker</span><span class="p">)</span><span class="w"> </span><span class="nf">State</span><span class="p">()</span><span class="w"> </span><span class="nx">Status</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nx">now</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="nf">effectiveNow</span><span class="p">(</span><span class="nf">wallClockNow</span><span class="p">())</span><span class="w"> </span><span class="c1">// 算出证书自己的有效时间</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">return</span><span class="w"> </span><span class="nx">currentLicense</span><span class="p">.</span><span class="nf">StatusAt</span><span class="p">(</span><span class="nx">now</span><span class="p">)</span><span class="w"> </span><span class="c1">// 再查该时间点的状态</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">}</span><span class="w">
</span></span></span></code></pre></td></tr></table>
</div>
</div><p>Go 的 <code>time.Time</code> 内部同时保存墙上时钟读数和单调读数，<code>time.Now()</code> 返回的时间点也携带两者。当两个 <code>time.Time</code> 都带有单调读数时，<code>time.Since(start)</code> 优先用单调读数相减，得到的是进程真实运行时长，而不是墙上时钟的差值：</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span><span class="lnt">3
</span><span class="lnt">4
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="cl"><span class="nx">start</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="nx">time</span><span class="p">.</span><span class="nf">Now</span><span class="p">()</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="c1">// 客户通过 settimeofday 把墙上时钟往回拨一年</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nx">elapsed</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="nx">time</span><span class="p">.</span><span class="nf">Since</span><span class="p">(</span><span class="nx">start</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="c1">// elapsed 仍然是进程启动后真实流逝的时间，不受墙上时钟回拨影响</span><span class="w">
</span></span></span></code></pre></td></tr></table>
</div>
</div><p>但单调读数不能持久化：进程重启会归零，格式化或序列化也会丢掉。所以锚点用墙上时钟落盘，单调部分只存于内存，两者各司其职。</p>
<p>我们还希望锚点文件的语义不要过于明显。如果文件名或内容带有明显语义，查看目录的人就会知道「这是回拨防御状态，删了它就能重置」。所以它被存成 8 字节小端 uint64 裸二进制，不带字段名、不是 JSON、文件名也不带语义。</p>
<h3 id="33-安全边界">3.3 安全边界</h3>
<p>这层防御不承诺防 root。有 root 权限的客户可以直接删除锚点文件或 patch 二进制删掉时间判定，那才是真正的边界。删除之外的另一种操作是备份早期锚点文件、之后再配合冻结时钟恢复它，让有效时间回退到备份点；但这类操作和「删锚点文件再冻结」一样需要持续干预，无法一次操作永久生效，不改变整体的防御定位。我们同时用二进制混淆把校验逻辑和锚点机制隐藏起来（见第 4 节），把绕过的成本从直接删除文件抬高到需要认真逆向；交付流程则负责控制证书授权周期和续期通道。</p>
<p>另外两个小边界是：进程停止期间时间不走，因为服务本就不在提供；Linux 的 CLOCK_MONOTONIC 不计系统 suspend 时长，VM 挂起期间时间暂停。</p>
<h3 id="34-测试策略把两个时钟都注入">3.4 测试策略：把两个时钟都注入</h3>
<p>这套机制的正确性很大程度上集中在时间判定上，而时间测试最容易写成 <code>time.Sleep</code> 式的脆弱用例。我们的做法是把墙上时钟和单调时钟都做成依赖注入：在代码里提供两个可替换的时钟源，一个返回当前 wall time，一个返回当前 uptime。测试时把它们换成手动推进的假时钟，所有有效期判定都走这两个注入源，不再依赖真实时间。</p>
<p>几类原本难以构造的场景，由此可以写成普通单测：墙上时钟冻结在过期前、单调时钟推 30 天，断言证书 expired；冻结期间锚点照常落盘，重新构造校验器模拟重启，从落盘锚点继续走，再推几天，断言 expired，验证「冻结加反复重启」无法重置时间；宽限期边界前后 1 秒的状态迁移、回拨超容忍度的告警，也都是表驱动断言。</p>
<h2 id="4-garble-二进制符号混淆">4. garble 二进制符号混淆</h2>
<p>前面几节多次提到 garble，这一节集中说明它的原理、能力边界，以及围绕它做的几个验证。</p>
<p><a href="https://github.com/burrowers/garble">garble</a> 的原理是包装 Go 工具链的 <code>toolexec</code>，在编译期替换标识符：包路径、类型名、函数名、变量名全部被替换成无意义的哈希串，同时裁剪符号表与调试信息。开启 <code>-literals</code> 后，连字符串和数字字面量也会被混淆，运行时才还原。最终构建产物里既没有可读的调用栈，也没有能直接定位 license 校验逻辑的符号名。</p>
<h3 id="41--literals-混淆什么不混淆什么">4.1 -literals 混淆什么、不混淆什么</h3>
<p>garble 的 <code>-literals</code> 模式会对源码中的字符串和字节字面量做 AST 级变换，把字面量替换成运行时现场解密的匿名函数，使用 XOR/ADD/SUB、随机 key、swap、split、shuffle、seed 等策略，并通过 external keys 和 proxy dispatcher 把值藏进随机嵌套 struct。这套机制会让字面量在二进制里以碎片形式出现，加上 junk 字节和符号混淆，单个字面量的边界并不突出。</p>
<p>源码里的普通字面量在它的保护范围内。我们以 XOR 掩码做过验证：32 字节的掩码以 <code>var keyMask = []byte{...}</code> 形式写在源码里，普通构建的二进制里能直接搜到连续字节，但经 <code>garble -literals</code> 混淆后，原始掩码序列在二进制里无法直接搜到，静态 <code>strings</code> 或十六进制搜索都拿不到。</p>
<p>但 <code>-literals</code> 只覆盖「源码里的字面量」，有两类数据不在它的处理范围内，我们同样做过验证。</p>
<p><strong><code>-ldflags -X</code> 注入的值不会被混淆。</strong> 用 garble v0.17.0 + Go 1.26.5 验证：第 1 节的注入命令产出的二进制中，<code>REAL_PUBLIC_KEY_VALUE</code> 仍能被 <code>strings</code> 找到，而源码中硬编码的普通常量在同一次构建里是被混淆的。另一个问题是 garble 会重命名标识符，<code>-X</code> 指定的包路径和变量名需要 garble 能正确解析。garble 仓库的 issue <a href="https://github.com/burrowers/garble/issues/717">#717</a> 跟踪的正是这个问题：<code>-literals</code> 与 <code>-ldflags</code> 配合时注入值未被混淆，至今未修复（README 中「<code>-literals</code> 也会替换 <code>-X</code> 注入的字符串」的说法与此不符，属于过时信息）；<a href="https://github.com/burrowers/garble/issues/820">#820</a> 则显示 ldflags 在 garble 测试套件中曾触发 ABI 相关失败。</p>
<p><strong><code>go:embed</code> 嵌入的文件字节不会被混淆。</strong> garble 能正常编译 embed，但经实测，嵌入的明文会原样出现在二进制里。embed 数据在二进制里是一个连续的 blob，前面跟着 Go runtime 生成的 slice header，garble 不会处理这个 header，也不会拆分或伪装这块数据。这正是第 1 节选择对公钥做可逆变换、而不是直接嵌入明文的原因。</p>
<h3 id="42-与可逆变换的分工">4.2 与可逆变换的分工</h3>
<p>可逆变换和 garble 的分工不同：可逆变换藏的是内容，让公钥不以可识别结构明文存在；garble 藏的是结构，让校验逻辑的位置和调用关系不可读。它们都不构成密码学边界，但合力把简单修改的成本抬高到了需要逆向分析的程度。</p>
<h2 id="5-真实攻击路径与安全边界">5. 真实攻击路径与安全边界</h2>
<p>按攻击成本从低到高，可以回顾这套设计挡住了什么、没挡住什么。</p>
<p>最低成本的攻击——用 <code>strings</code> 命令搜索二进制找到 PEM 公钥，替换后自签证书——已经被挡住。公钥不是 PEM 明文，而是经过可逆变换的 DER 数据；掩码本身也被 garble 字面量混淆，静态搜索拿不到。这意味着简单的静态替换不再奏效。</p>
<p>再往上走，攻击者可以定位到 embed 的边界（slice header 里的长度和指针），确认这里有一块变换后的数据。但想还原出公钥，仍然需要拿到 XOR 掩码。静态逆向 garble 的字面量混淆可以恢复掩码，成本明显高于字符串替换；动态调试则能在运行内存里直接读出还原后的掩码。</p>
<p>更实际的攻击路径会绕开公钥和掩码本身：直接 patch 二进制让验签入口永远放行，或者让掩码还原步骤原样返回输入。有 root 权限的客户也可以删除锚点文件重置时间防御。这些场景不在设计目标之内。</p>
<p>所以这套机制的真实定位是：挡住无预谋的静态替换和误用，把有预谋的绕过成本抬高到需要逆向、调试或重新编译；最终保障仍然交给短授权周期和顺畅续期。它不是密码学边界，而是一道工程门槛。</p>
<h2 id="结语">结语</h2>
<p>整套设计的基本取舍是如实描述能力边界：承认本地没有可信时钟和不可跳过的校验，把工程投入放在抬高轻易绕过的成本上——可逆变换、混淆、落盘文件的去语义化、锚点推进——同时把商业保障交给短授权周期和顺畅续期，比如 UI 上传即生效、无需重启、无需 SSH 上节点。</p>
<p>私有化 License 机制无法做到绝对安全，但可以让正常客户不会误用，让未授权使用不再零成本。</p>
]]></description>
    </item>
  </channel>
</rss>
