Files
blog/blog-admin/src/lib/acme.ts
T
zqlit b821b78718 fix(ssl): 修掉 5 处静默失败,本项目全面接管签发部署,Worker 退回只读
一、1Panel 部署器三个缺陷(其中两个此前完全不可见)

1. `POST /websites/{id}/https` 的字段名是 `websiteSSLId`,不是 `sslId`。
   发 `sslId` 会被 Go 静默忽略成零值 0,于是
   `websiteSSLRepo.GetFirst(WithByID(0))` → 返回
   `HTTP 200 + code 500「服务错误: record not found」`,
   报错文案落在 DB 层,完全指不到参数名 —— 整个 t-t.live 部署被这条卡住。
   对照实验:`sslId=13 → 500` / `websiteSSLId=13 → 200 code=200`。

2. 换证书内容的接口选错。`POST /websites/ssl/update` 的结构体
   `WebsiteSSLUpdate` **根本没有** `certificate` / `privateKey` 字段,
   传了被丢弃、且 `domains` 只从 `otherDomains` 取(不传就清空),
   还会顺带把 `autoRenew` 置 false —— 而它**照样返回 200 success**。
   实测:原样 update 后 5 个站点的 `ssl/*.pem` mtime+md5 一个都没变。
   正确接口是 `POST /websites/ssl/upload` + `sslID > 0`:取记录 → 覆盖
   → 重算 ExpireDate/domains → `UpdateSSLConfig()` → 重新物化站点文件。
   副作用:`Upload()` 把 `primaryDomain` 重算成证书第一个 SAN
   (#11 因此从 `usj.cc` 漂成 `*.usj.cc`)→ 必须补 `domains` 兜底匹配,
   否则每次续期都新建一条重复记录。

3. `deploy()` 幂等捷径漏了「记录被换过」这一维:`sslId` 没变但内容变了时
   会跳过绑定,站点文件就停留在旧证书。改为引入 `replaced` 标志强制重绑。

二、另外两处静默失败

4. `parsePemInfo()` 对**完整链**返回 `{}`:旧实现把 PEM 各段 base64 拼接后
   一次 `atob`,中间段尾部的 `=` 填充导致抛错,整函数返回空 →
   `rec.expireAt` 退化成「签发时刻 + 90 天」。而完整链恰恰是部署器最常
   拿到的形态。改为只解析第一段(叶证书)。
   顺带新增 `derLen()` / `parseSanFromDer()`,精确定位 SAN 扩展
   OID `2.5.29.17` 再读 `[2] dNSName`,替掉原来的字节扫描启发式。
   这条同时是「多吉云复用失效」的根因:判据缺到期时间,只看域名集合
   就永远认为已覆盖 → 续期静默空转。修好后判据带上 `notAfter` 比对(1 天容差)。

5. DNS-01 挑战通知会撞 `400 authorization must be pending`(200181.xyz 连中两次)。
   这是**竞态**不是逻辑错:「先读状态再 POST」挡不住毫秒级窗口。
   已在 POST 侧做幂等容错(只认这一句),最终由 `pollAuthz` 定论。

三、Worker 退回只读

- `crons` 去掉 `10 4 * * *`,`index.ts` 里 renew 分支整体删除
- `POST /ssl/issue` 改 **501 硬拒绝**(而不是静默降级),响应给出国内机命令
- 签发 + 部署整条链路跑在国内机容器 `cn-certkeeper`

四、顺带修掉的两个「配置被悄悄抹掉」

- `configSave()` 不再丢掉表单不管理的 `probe_connect` / `probe_sni`。
  之前管理员在面板改任何一项,这两个字段就会被清空,
  后果是挂在 CDN 后的 t-t.live 探针退回公网、被误判成「还剩 80 多天」,
  源站证书到期也不续。现在保存时从旧配置带过来。

五、新增 4 个常驻运维工具(deploy/cn-certkeeper/src/)

- `renew-one.mjs`      只对单个域名签发+部署(原 `/renew` 无域名过滤,会全量重签)
- `redeploy.mjs`        复用已签好的证书只重跑部署(不碰 ACME,不白烧配额)
- `rollback-dogecloud.mjs` 应急把 CDN 域名绑回指定证书 id
- `txt-inspect.mjs`     `_acme-challenge` 下的 TXT 残留盘点/清理
- `selfcheck-acme.mjs`  CA 层诊断:只读目录 + 复用账户,不签发不部署

六、测试与文档

- 自测新增 [11] 节 8 项 PEM 解析回归(样本是 openssl 现场生成、内联写死的
  叶+中间证书,两段都以 `=` 结尾,正是 bug 现场),含精确值断言:
  > 125 项通过,0 失败
- `npm run typecheck` 零错误
- 方案文档:§9.11 由「待验证」改为定案(`ssl/update` 不物化、
  `ssl/upload+sslID` 才物化);新增 §9.12「本轮又修掉的 5 个静默失败」、
  §9.13「本轮最终状态」、§9.14「certimate 工作流清查」

线上验收:三个域名(t-t.live / usj.cc / 200181.xyz)线上证书均为
LiteSSL ECC、2027-01-04 到期、daysLeft=90、needRenew=false;
1Panel 证书库 5 条精简为 3 条且全部在用;
certimate 停掉全部「会签发并部署」的工作流(团团 / 优世界 / 200181.xyz),
保留三条纯监控告警。
2026-10-06 20:08:28 +08:00

836 lines
37 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* ACME v2 客户端 —— 纯 WebCrypto + fetch,零依赖。
*
* 为什么自己写而不引 npm 包(acme-client / acme-js 等):
* · Workers 里跑,包的体积直接进冷启动;acme-client 依赖 node:crypto 的
* 一堆 Node 专属 API,在 Workers 上要么跑不了要么要 shim;
* · 我们要的能力很窄:ECDSA 账户密钥 + DNS-01 + 签发 + 下载链,
* RFC 8555 那几百行核心逻辑自己写反而更可控(也更好排错)。
*
* ★ 实现要点(每条都踩过或差点踩):
*
* ① **JWS 用 ES256**:WebCrypto 的 `subtle.sign('ECDSA')` 返回的是
* **原始 r||s(64 字节)**,而 JWS 的 ES256 要的正是这个格式 ——
* 不是 DER。所以**不要**再包一层 DER 编码(很多 Node 实现要转,Workers 不要)。
*
* ② **JWK thumbprint**:RFC 7638 规定,必须按**字典序**取字段拼
* `{"crv":...,"kty":...,"x":...,"y":...}`(不能带别的字段、不能有空格),
* 再做 SHA-256 + base64url。`kid` 就是它。拼错一个字,整个符合验签必挂。
*
* ③ **重放随机数**:每个请求必须带**新的** nonce(服务端给一次用一次)。
* 我们从 `Replay-Nonce` 响应头拿;拿不到时**不能瞎编**,得去 newNonce
* 端点要一个。这里统一在 `post()` 里用「先用缓存、无则现取」。
*
* ④ **badNonce 要重试**:网络抖动会让服务端认为 nonce 已用过。ACME 规范
* 明确要求客户端遇到 `urn:ietf:params:acme:error:badNonce` **重试**。
* 不重试就会偶发失败 —— 而续期是无人值守的,偶发失败=证书过期。
*
* ⑤ **EAB(External Account Binding)**:LiteSSL / ZeroSSL 这类商用 CA 要求
* 注册时用 CA 给的 kid + HMAC key 签一个内层 JWS。内层用 HS256
* (`subtle.importKey('raw', ..., {name:'HMAC', hash:'SHA-256'})`)。
*
* ⑥ **DNS-01 的 key authorization**:`token + '.' + thumbprint` 再做
* SHA-256 的 **base64url**(RFC 8555 §8.4)。注意是 base64**url**,
* 服务端比对的正是这个串,用标准 base64 会一直 pending 到超时。
*/
// ==================================================================== 类型
export interface AcmeAccount {
/** 账户私钥(JWK 形式长期保存,比 PEM 好管理) */
jwk: JsonWebKey;
/** 账户 URL(kid),签发时必须带 */
kid: string;
/** 服务端目录地址,账户与 CA 绑定 */
directoryUrl: string;
}
export interface AcmeIssueResult {
/** 证书链 PEM(叶 + 中间) */
cert: string;
/** 私钥 PEM */
key: string;
/** 实际签发了哪些域名 */
domains: string[];
}
export interface AcmeLogger {
(msg: string): void;
}
interface Directory {
newNonce: string;
newAccount: string;
newOrder: string;
/** ACME v2 用 `revokeCert` 之外,下载走订单自身的 certificate 字段 */
keyChange?: string;
revokeCert?: string;
}
// ==================================================================== 小工具
const enc = new TextEncoder();
/**
* `crypto.subtle.exportKey` 的返回类型是 `ArrayBuffer | JsonWebKey`,
* 按 format 字符串字面量收窄不了。这里包一层断言,
* 省得每个调用点都写 `as`(也更清楚:'jwk' 出 JWK,其余出 ArrayBuffer)。
*/
const exportJwk = (k: CryptoKey): Promise<JsonWebKey> =>
crypto.subtle.exportKey('jwk', k) as Promise<JsonWebKey>;
const exportDer = (k: CryptoKey, format: 'pkcs8' | 'spki' | 'raw'): Promise<ArrayBuffer> =>
crypto.subtle.exportKey(format, k) as Promise<ArrayBuffer>;
function b64u(bytes: ArrayBuffer | Uint8Array): string {
const b = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
let s = '';
for (let i = 0; i < b.length; i++) s += String.fromCharCode(b[i]);
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function b64uJson(v: unknown): string {
return b64u(enc.encode(JSON.stringify(v)));
}
/**
* 取出 JWK 的**公开部分** —— 只保留该密钥类型定义的那几个成员。
*
* ★★ 为什么必须有这一步(2026-10-06 实测踩坑,代价是一次全线签发失败):
*
* 账户密钥是 `exportKey('jwk', privateKey)` 出来的,里面**同时**带着
* `d`(私钥标量)、`key_ops`、`ext`。早先我们把这个对象**原样**塞进
* `protected.jwk`(以及 EAB 的 payload),发出去长这样:
*
* {"alg":"ES256","nonce":"…","url":"…",
* "jwk":{"key_ops":["sign"],"ext":true,"kty":"EC","x":"…","y":"…","crv":"P-256","d":"…"}}
*
* 症状:LiteSSL 回 `403 {"detail":"newAccount JWS signature is invalid"}`。
* 而**本地验签是过的** —— 拿 x/y 造公钥、验 raw r||s,结果 true。
* 也就是说:问题不在签名算法,而在 JWK 的**内容语义**。
*
* 规范依据:
* · RFC 8555 §6.2 —— `jwk` 字段必须是**公钥**;
* · RFC 8555 §7.3.4 —— EAB 的内层 payload 同样是「账户公钥的 JWK 形式」;
* · RFC 7517 §6.2.1 —— EC 公钥只定义 crv/kty/x/y 四个成员。
* 多出来的成员会让严格实现(go-jose / 自研校验)拒绝、或把 thumbprint 算歪。
*
* ★ 顺带还是一个**安全修复**:私钥标量 `d` 绝不该发到 CA 那边去。
* (`jwkThumbprint()` 一直只用 crv/kty/x/y,所以它不受影响、无需统一。)
*/
function publicJwk(jwk: JsonWebKey): JsonWebKey {
switch (jwk.kty) {
case 'EC':
return { kty: 'EC', crv: jwk.crv, x: jwk.x, y: jwk.y };
case 'RSA':
return { kty: 'RSA', n: jwk.n, e: jwk.e };
case 'OKP':
return { kty: 'OKP', crv: jwk.crv, x: jwk.x };
default: {
// 兜底:调用方给了没见过的 kty,至少把**所有私钥/语义成分**摘干净
const out: JsonWebKey = { ...jwk };
for (const k of ['d', 'p', 'q', 'dp', 'dq', 'qi', 'o', 'k', 'key_ops', 'ext', 'use', 'alg'] as const) {
// 走一次 unknown:`JsonWebKey` 没有索引签名,直接转 Record 会被 TS 判为可疑转换
delete (out as unknown as Record<string, unknown>)[k];
}
return out;
}
}
}
/** PEM 换行(64 列),末尾留换行 —— 多数软件(1Panel / nginx)都要求这样 */
function toPem(der: ArrayBuffer, label: string): string {
const b = new Uint8Array(der);
let s = '';
for (let i = 0; i < b.length; i++) s += String.fromCharCode(b[i]);
const b64 = btoa(s);
const lines = b64.match(/.{1,64}/g) || [];
return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----\n`;
}
// ==================================================================== ACME 客户端
export class AcmeClient {
private dir: Directory | null = null;
private nonce: string | null = null;
private readonly log: AcmeLogger;
/**
* ★ 账户私钥的 CryptoKey 缓存(2026-10-06 加)。
*
* 原来 signJws() 每次调用都 `importKey('jwk', ...)`。一次签发有 ~11 次 JWS,
* 本地实测(.editor-tmp/cpu-bench4.mjs,3000 次迭代):
* importKey('jwk') 单次 126 µs;若密钥已就绪,纯 sign 只要 84 µs。
* 也就是说每次签发白烧 ≈ 1.4 ms。Workers 免费版 CPU 硬顶 10 ms,
* 这 1.4 ms 值得省;就算跑在国内机,少一次密钥解析也没坏处。
*
* 缓存安全性:账户密钥(this.account.jwk)在实例生命周期内**不变**,
* 而一次签发自始至终用同一个实例(见 certissue.ts 的 issueDomain)。
*
* 存 Promise 而不是 CryptoKey:并发调用时只真正 import 一次
* (存 CryptoKey 的话,两个并发请求会各 import 一次,结果一样但白花 CPU)。
*/
private signingKey: Promise<CryptoKey> | null = null;
constructor(
private readonly directoryUrl: string,
private readonly account: { jwk: JsonWebKey; kid: string },
log?: AcmeLogger,
) {
this.log = log || (() => {});
}
// ---------------------------------------------------------- 底层请求
private async directory(): Promise<Directory> {
if (this.dir) return this.dir;
const r = await fetch(this.directoryUrl, { headers: { Accept: 'application/json' } });
if (!r.ok) {
// ★ 这个报错值得写细一点:2026-10-06 实测 `https://acme.trustasia.com/acme/directory`
// 是 404(正确地址是 `.../acme/v2/directory`,中间少了 `/v2`)。
// 目录地址写错是这一层最常见的故障,而默认报错只会给一句 HTTP 404,
// 排查时容易误以为是网络/防火墙问题,所以这里把「URL 本身」顶到最前面。
throw new Error(
`ACME 目录不可达:HTTP ${r.status}(${this.directoryUrl})` +
`—— 请核对 directoryUrl,多数 CA 的目录地址需要带版本段(如 …/acme/v2/directory)`,
);
}
const d = (await r.json()) as Directory;
if (!d.newNonce || !d.newAccount || !d.newOrder) {
throw new Error(`ACME 目录结构异常(${this.directoryUrl}):缺少 newNonce/newAccount/newOrder`);
}
this.dir = d;
return d;
}
/** 目录里是否声明「必须绑定外部账户(EAB)」——LiteSSL/ZeroSSL 都是 true */
async externalAccountRequired(): Promise<boolean> {
const d = (await this.directory()) as Directory & { meta?: { externalAccountRequired?: boolean } };
return d.meta?.externalAccountRequired === true;
}
private async takeNonce(): Promise<string> {
if (this.nonce) {
const n = this.nonce;
this.nonce = null;
return n;
}
const d = await this.directory();
// HEAD 也能拿(规范允许),GET 更稳 —— 有些中间设备会把 HEAD 的 header 吞掉
const r = await fetch(d.newNonce, { method: 'GET' });
const n = r.headers.get('Replay-Nonce');
if (!n) throw new Error('ACME 服务器没有返回 Replay-Nonce');
return n;
}
/**
* 有 kid → 用 kid;没有(注册阶段)→ 用 jwk。
*
* ★ 用 `publicJwk()` 裁剪,**不能**直接把 `this.account.jwk` 放进去 ——
* 那里面带着私钥 `d` / `key_ops` / `ext`,会被 CA 判签名无效,
* 顺带还把私钥发出去了。详见 publicJwk() 的注释。
*/
private protectedHeader(nonce: string, url: string): Record<string, unknown> {
const base: Record<string, unknown> = { alg: 'ES256', nonce, url };
if (this.account.kid) base.kid = this.account.kid;
else base.jwk = publicJwk(this.account.jwk);
return base;
}
/**
* 取(并缓存)账户签名密钥 —— 见 signingKey 字段注释。
* 只在第一次调用时真的 importKey,之后复用同一个 CryptoKey。
*/
private importSigningKey(): Promise<CryptoKey> {
if (!this.signingKey) {
// 失败时清掉缓存,否则一次网络/参数抖动会被永久缓存成 reject
this.signingKey = (crypto.subtle.importKey(
'jwk',
this.account.jwk,
{ name: 'ECDSA', namedCurve: 'P-256' },
false,
['sign'],
) as Promise<CryptoKey>).catch((e) => {
this.signingKey = null;
throw e;
});
}
return this.signingKey;
}
private async signJws(protectedHeader: Record<string, unknown>, payload: unknown): Promise<string> {
const key = await this.importSigningKey();
// ★ payload === undefined 表示「**真的空** payload(零字节)」,见 postAsGet()。
// 千万别图省事传 `''` —— `b64uJson('')` 编出来是 `IiI`(JSON 字符串 `""`),
// 服务端 base64url 解码拿到两个字符 `"`,而不是空。
// LiteSSL 实测直接回 400 `Expected JWS payload message`;ZeroSSL 宽容放行 ——
// 所以这个 bug 在换 CA 之前一直藏着。
const p = payload === undefined ? '' : b64uJson(payload);
const signingInput = `${b64uJson(protectedHeader)}.${p}`;
const sig = await crypto.subtle.sign(
{ name: 'ECDSA', hash: 'SHA-256' },
key,
enc.encode(signingInput),
);
// ★ 不包 DER —— WebCrypto 已经是 JWS 要的 r||s
return JSON.stringify({ protected: b64uJson(protectedHeader), payload: p, signature: b64u(sig) });
}
/** 发一个 POST;自动带 nonce、自动在 badNonce 时重试 */
private async post(
url: string,
payload: unknown,
opts: { useJwk?: boolean; retries?: number; accept?: string } = {},
): Promise<Response> {
const retries = opts.retries ?? 2;
const dir = await this.directory();
void dir;
for (let attempt = 0; ; attempt++) {
// 注册时用 jwk(此时还没有 kid),其余用 kid。用临时对象绕开
const savedKid = this.account.kid;
if (opts.useJwk) this.account.kid = '';
try {
const nonce = await this.takeNonce();
const body = await this.signJws(this.protectedHeader(nonce, url), payload);
const r = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/jose+json',
// ★ 只有**下载证书**那一步要用 `application/pem-certificate-chain`
// (RFC 8555 §7.4.2),其余一律 JSON。传错会怎样见 postAsGet 的注释。
Accept: opts.accept || 'application/json',
},
body,
});
const n = r.headers.get('Replay-Nonce');
if (n) this.nonce = n; // 缓存下一个 nonce,省一次往返
if (r.ok) return r;
// 非 2xx:判断是不是 badNonce(可重试),其余直接抛
const text = await r.text();
let type = '';
try {
type = (JSON.parse(text) as { type?: string }).type || '';
} catch {
/* 不是 JSON 就按原文处理 */
}
if (type.includes('badNonce') && attempt < retries) {
this.nonce = null; // 强制重新取
continue;
}
throw new Error(`ACME 请求失败 HTTP ${r.status}:${text.slice(0, 300)}`);
} finally {
if (opts.useJwk) this.account.kid = savedKid;
}
}
}
/**
* POST-as-GET(RFC 8555 §6.3):读资源也要用 POST 签名,不能直接 GET。
*
* ★ payload 必须是**零长度的八位字节串**,不是 JSON 的 `null`(那是「让服务端
* 删掉字段」),也**不是** JSON 的空字符串 `""`。这里传 `undefined` 走
* signJws() 的空 payload 分支 —— 传 `''` 会编成 `IiI`,LiteSSL 会 400。
*
* ★ `accept` 只在**下载证书**时需要传 `application/pem-certificate-chain`,
* 见下面 downloadCert() 的注释(这一条最坑,2026-10-06 排查了很久)。
*/
private async postAsGet(url: string, accept?: string): Promise<Response> {
return this.post(url, undefined, { accept });
}
/**
* 下载证书链。
*
* ★★ 必须显式声明 `Accept: application/pem-certificate-chain`(RFC 8555 §7.4.2)。
*
* 2026-10-06 实测:这一步是整个签发链路的**最后一个坑**,而且最难定位 ——
* newOrder / 授权 / finalize / 轮询订单**全部 200**、订单状态确实走到了
* `valid`(证书已经在 CA 那边签出来了),然后下载那一步回
* 500 {"type":"…:serverInternal","detail":"The server experienced an internal error"}
* —— 看上去像「签发被内部错误挡住了」,实际是**内容协商**:
* 我们一直发 `Accept: application/json`(post() 的默认值),
* 而 LiteSSL 只实现了 PEM 这一种媒体类型,协商不上就内部 500。
* (零依赖实现 ACME 极易漏掉这一条:其它客户端都把 Accept 交给 http 库的
* content-type 协商处理,我们手写 fetch 就必须自己写对。)
*/
private async downloadCert(url: string): Promise<string> {
const r = await this.postAsGet(url, 'application/pem-certificate-chain');
return r.text();
}
// ---------------------------------------------------------- 账户
/**
* 注册(或找回)账户。
* @param contact 邮箱,如 ['mailto:me@example.com'];可空
* @param eab CA 要求的外部账户绑定(LiteSSL/ZeroSSL 必填)
*/
async registerAccount(contact: string[], eab?: { kid: string; hmacKeyB64: string }): Promise<string> {
const d = await this.directory();
const payload: Record<string, unknown> = {
termsOfServiceAgreed: true,
...(contact.length ? { contact } : {}),
};
if (eab) {
// ★ 内层 JWS:protected 只放 alg/kid/url,payload 是账户**公钥**的 JWK
// (RFC 8555 §7.3.4 —— 同样是公钥,别把带 d 的私钥对象塞进去)
const innerProtected = b64uJson({ alg: 'HS256', kid: eab.kid, url: d.newAccount });
const innerPayload = b64uJson(publicJwk(this.account.jwk));
// ★ EAB 的 hmac key 各家编码不一:ZeroSSL 给标准 base64,LiteSSL 给 base64url。
// 先统一成标准 base64,**再补上 padding** —— 有些运行时(严格模式的 atob)
// 对缺 `=` 的串会直接抛 InvalidCharacterError,而错在这是「账户注册」这一步,
// 报出来会很难往「少了两个等号」上想。
const b64 = eab.hmacKeyB64.replace(/-/g, '+').replace(/_/g, '/');
const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4);
const rawKey = Uint8Array.from(atob(padded), (c) => c.charCodeAt(0));
const hmacKey = await crypto.subtle.importKey('raw', rawKey, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
const innerSig = await crypto.subtle.sign('HMAC', hmacKey, enc.encode(`${innerProtected}.${innerPayload}`));
payload.externalAccountBinding = {
protected: innerProtected,
payload: innerPayload,
signature: b64u(innerSig),
};
}
const r = await this.post(d.newAccount, payload, { useJwk: true });
// ★ 账户已存在时服务端回 200 + Location(不是 201),一样取 Location
const kid = r.headers.get('Location');
if (!kid) throw new Error('ACME 注册成功但没有返回 Location(拿不到账户 URL)');
this.account.kid = kid;
return kid;
}
// ---------------------------------------------------------- 签发
/**
* DNS-01 签发。
*
* @param domains 要签的域名(第一个作为 CN,其余进 SAN)
* @param setTxt 写 TXT 记录:`(name, value) => Promise<void>`
* @param clearTxt 删 TXT 记录(失败不影响签发,只记日志)
* @param opts.waitSeconds 写完后等多久让 DNS 生效(默认 30s,DNSPod 一般 10s 内)
*/
async issueDns01(
domains: string[],
setTxt: (name: string, value: string) => Promise<void>,
clearTxt: (name: string, value: string) => Promise<void>,
opts: { waitSeconds?: number; timeoutMs?: number } = {},
): Promise<AcmeIssueResult> {
if (!domains.length) throw new Error('至少要一个域名');
const d = await this.directory();
const thumbprint = await this.jwkThumbprint();
// ① 下订单
const orderRes = await this.post(d.newOrder, { identifiers: domains.map((v) => ({ type: 'dns', value: v })) });
const order = (await orderRes.json()) as {
status: string;
authorizations: string[];
finalize: string;
certificate?: string;
};
const orderUrl = orderRes.headers.get('Location') || '';
this.log(`订单已创建(${domains.join(', ')}),状态 ${order.status}`);
// ② 逐个授权:读状态 → 算 TXT → **把 TXT 全写完**(先不通知 CA)
//
// ★ 为什么「先写完再统一等」(2026-10-06 实测踩坑):
// wildcard + apex(`*.t-t.live` 与 `t-t.live`)在 ACME 里是**两张独立授权**,
// 但 DNS-01 的 TXT 名字是**同一个** `_acme-challenge.t-t.live`,
// 两张授权各自的期望值**不同** —— 必须**同时存在**才能一次验过。
// 老写法是「写一条 → 等 30s → 通知 → 轮询 → 再写下一条」,
// 等于给每条授权各等 30s(60s 白等),而且第二张常常在等待期间
// 被服务端顺带置成 valid → 见下面 ④ 的说明。
const written: { name: string; value: string }[] = [];
const pending: { authzUrl: string; chalUrl: string; label: string }[] = [];
try {
for (const authzUrl of order.authorizations) {
const authz = (await (await this.postAsGet(authzUrl)).json()) as {
status: string;
identifier: { value: string };
challenges: { type: string; url: string; token: string; status: string }[];
};
if (authz.status === 'valid') {
this.log(`${authz.identifier.value} 已授权(跳过)`);
continue;
}
const chal = authz.challenges.find((c) => c.type === 'dns-01');
if (!chal) throw new Error(`${authz.identifier.value} 没有 dns-01 挑战(CA 不支持 DNS 验证?)`);
// ★ key authorization:token + '.' + thumbprint,再做 SHA-256 并 base64url
const keyAuth = `${chal.token}.${thumbprint}`;
const digest = await crypto.subtle.digest('SHA-256', enc.encode(keyAuth));
const txtValue = b64u(digest);
const recName = `_acme-challenge.${stripWildcard(authz.identifier.value)}`;
this.log(`写 TXT:${recName} = ${txtValue.slice(0, 16)}…`);
await setTxt(recName, txtValue);
written.push({ name: recName, value: txtValue });
pending.push({ authzUrl, chalUrl: chal.url, label: authz.identifier.value });
}
// ③ 等 DNS 传播 —— **所有 TXT 写完后统一等一次**
// 不给自己留这个时间,验证会一直 pending 到超时。
if (pending.length) {
const wait = opts.waitSeconds ?? 30;
if (wait > 0) {
this.log(`等 ${wait}s 让 DNS 生效…`);
await sleep(wait * 1000);
}
}
// ④ 逐个「通知 CA 开始验证」+ 轮询
//
// ★★ **绝不能盲发 challenge**(2026-10-06 实测踩坑,整张证书签不出来):
// 对一张**非 pending** 的授权发 challenge,LiteSSL 回
// 400 urn:ietf:params:acme:error:malformed "authorization must be pending"
// wildcard + apex 共用一个 TXT 名字,第一张验过之后服务端常把第二张
// 一起置为 valid —— 此时它已经不是 pending 了,不必(也不能)再通知。
// 所以发之前**重新读一次状态**:还是 pending 才发;否则交给 pollAuthz 定论
// (valid → 通过,invalid → 由 pollAuthz 抛出带原因的错,信息不丢)。
//
// ★★ 但「先读再发」**挡不住竞态**(2026-10-06 下午 200181.xyz 实测又踩):
// 读到的确是 pending,可等到 POST 打到服务端时状态已经翻过去了 ——
// 两个请求之间只差毫秒,这个窗口关不掉。
// 所以还必须在 POST 这一侧做**幂等容错**:把「authorization must be pending」
// 当成「已经不需要通知了」而不是错误,交给 pollAuthz 定论。
// 判据要卡得很死:只认这一句,且后续仍走 pollAuthz ——
// 真出问题(比如 DNS 没生效导致 invalid)依旧会由 pollAuthz 抛出带原因的错误,
// 不会把失败吞掉。
for (const p of pending) {
const cur = (await (await this.postAsGet(p.authzUrl)).json()) as { status: string };
if (cur.status === 'pending') {
try {
await this.post(p.chalUrl, {});
} catch (e) {
const m = e instanceof Error ? e.message : String(e);
if (!/authorization must be pending/i.test(m)) throw e;
this.log(`${p.label} 通知验证时状态已翻过 pending(竞态),改由轮询定论`);
}
} else {
this.log(`${p.label} 状态已变为 ${cur.status}(等待期间被服务端置位,跳过通知)`);
}
await this.pollAuthz(p.authzUrl, opts.timeoutMs ?? 180_000);
}
// ④.5 ★ 等订单进入 `ready` 再 finalize(RFC 8555 §7.1.1 / §7.4)
//
// ★★ 为什么必须有(2026-10-06 实测踩坑):
// 当授权是**被服务端复用**的(客户端读到的已经是 valid,上面直接 `跳过`),
// 订单在创建那一刻状态还是 `pending` —— 服务端还没来得及把它算成 `ready`。
// 此时直接 POST finalize,LiteSSL 回的是
// 500 {"type":"…:serverInternal","detail":"The server experienced an internal error"}
// 这句 500 完全指不到「订单没 ready」,害得人先去怀疑 CSR 的 DER 编码
// (离线用 openssl 验过,CSR 本身没问题)。
// 规范只允许在 `ready` 时 finalize,所以这里先轮询到位。
if (orderUrl) await this.waitOrderReady(orderUrl, opts.timeoutMs ?? 180_000);
// ⑤ finalize:CSR
const certKeyPair = (await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, [
'sign',
'verify',
])) as CryptoKeyPair;
const csrDer = await makeCsrImpl(certKeyPair, domains);
this.log('提交 CSR…');
await this.post(order.finalize, { csr: b64u(csrDer) });
// ⑥ 轮询订单直到 valid,然后下载证书链
const certUrl = await this.pollOrder(orderUrl, opts.timeoutMs ?? 180_000);
const certPem = await this.downloadCert(certUrl);
// ★ exportKey('pkcs8') 的 TS 重载返回 ArrayBuffer | JsonWebKey(因为 format 是联合),
// 这里 format 已确定是 pkcs8,用 exportDer 包装断言回 ArrayBuffer。
const pkcs8 = await exportDer(certKeyPair.privateKey, 'pkcs8');
const keyPem = toPem(pkcs8, 'PRIVATE KEY');
this.log('证书已签发 ✓');
return { cert: certPem.trim() + '\n', key: keyPem, domains };
} finally {
// ⑦ 无论成败都清理 TXT —— 留着 `_acme-challenge` 会干扰下次验证
for (const w of written) {
try {
await clearTxt(w.name, w.value);
} catch (e) {
this.log(`清理 TXT 失败(不影响签发):${e instanceof Error ? e.message : e}`);
}
}
}
}
/**
* 等订单进入 `ready` —— 只有 `ready` 才允许 finalize(RFC 8555 §7.1.1)。
*
* 正常路径下(授权是这次新验的)订单通常**瞬间**就是 ready,这个函数会
* 一次就返回;真正需要它的是「授权被服务端复用、订单仍是 pending」那种情况。
*
* `valid` 视作异常:说明订单在我们还没提交 CSR 的情况下就完成了 ——
* 那时我们手里没有与之匹配的私钥,拿着一张文不对题的证书比直接报错危险得多。
*/
private async waitOrderReady(url: string, timeoutMs: number): Promise<void> {
const deadline = Date.now() + timeoutMs;
let last = '';
for (;;) {
const o = (await (await this.postAsGet(url)).json()) as {
status: string;
error?: { detail?: string };
};
if (o.status === 'ready') {
if (last !== 'ready') this.log(`订单状态 ${last || '?'} → ready,可以 finalize`);
return;
}
if (o.status === 'valid') {
throw new Error('订单在 finalize 之前就变成 valid —— 客户端没有与之匹配的私钥,拒绝继续');
}
if (o.status === 'invalid') {
throw new Error(`订单在 finalize 前变为 invalid:${o.error?.detail || '未知原因'}`);
}
if (Date.now() > deadline) {
throw new Error(`订单迟迟没进入 ready(${timeoutMs / 1000}s,当前状态 ${o.status})`);
}
last = o.status;
await sleep(2000);
}
}
private async pollAuthz(url: string, timeoutMs: number): Promise<void> { const deadline = Date.now() + timeoutMs;
for (;;) {
const a = (await (await this.postAsGet(url)).json()) as {
status: string;
identifier: { value: string };
challenges: { type: string; error?: { detail?: string } }[];
};
if (a.status === 'valid') return;
if (a.status === 'invalid') {
const err = a.challenges.find((c) => c.error)?.error?.detail;
throw new Error(`${a.identifier.value} 验证失败:${err || '未知原因(多半是 TXT 没生效或值不对)'}`);
}
if (Date.now() > deadline) throw new Error(`${a.identifier.value} 验证超时(${timeoutMs / 1000}s)`);
await sleep(3000);
}
}
private async pollOrder(url: string, timeoutMs: number): Promise<string> {
const deadline = Date.now() + timeoutMs;
for (;;) {
const o = (await (await this.postAsGet(url)).json()) as {
status: string;
certificate?: string;
error?: { detail?: string };
};
if (o.status === 'valid' && o.certificate) return o.certificate;
if (o.status === 'invalid') throw new Error(`订单失败:${o.error?.detail || '未知原因'}`);
if (Date.now() > deadline) throw new Error(`订单超时(${timeoutMs / 1000}s)`);
await sleep(3000);
}
}
/** RFC 7638 JWK thumbprint —— 字段必须按字典序、不能多不能少 */
async jwkThumbprint(): Promise<string> {
const j = this.account.jwk;
const canonical = JSON.stringify({ crv: j.crv, kty: j.kty, x: j.x, y: j.y });
return b64u(await crypto.subtle.digest('SHA-256', enc.encode(canonical)));
}
}
// ==================================================================== 辅助
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
function stripWildcard(host: string): string {
return host.startsWith('*.') ? host.slice(2) : host;
}
/**
* 生成一个全新的 ACME 账户密钥(ECDSA P-256)。
*
* ★ 用 EC 而不是 RSA:Workers 的 CPU 时间有限,RSA-2048 生成在冷启动时
* 可能要几百毫秒到 1 秒,EC 只要几毫秒。ACME 两者都接受。
*/
export async function newAccountKey(): Promise<JsonWebKey> {
const kp = (await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, [
'sign',
'verify',
])) as CryptoKeyPair;
return exportJwk(kp.privateKey);
}
// ==================================================================== CSR(手写 DER)
/**
* 生成 PKCS#10 CSR(DER)。
*
* ★ 为什么不引 asn1.js / pkijs:我们只需要一种固定的结构
* (1 个 CN + N 个 SAN,签名算法 ECDSA-SHA256),硬编码 DER 模板
* 比引一个几百 KB 的 ASN.1 库划算得多,也少一个供应链面。
*
* DER 结构(RFC 2986):
* CertificationRequest ::= SEQUENCE {
* certificationRequestInfo SEQUENCE {
* version INTEGER (0),
* subject Name, -- CN=<第一个域名>
* subjectPKInfo SubjectPublicKeyInfo,
* attributes [0] { -- 里面塞 extensionRequest(SAN)
* SEQUENCE { OID 1.2.840.113549.1.9.14, SET { SEQUENCE { SAN 扩展 } } }
* }
* },
* signatureAlgorithm SEQUENCE { OID ecdsa-with-SHA256 },
* signature BIT STRING
* }
*/
async function makeCsrImpl(keyPair: CryptoKeyPair, domains: string[]): Promise<ArrayBuffer> {
const cn = domains[0];
const pub = await exportJwk(keyPair.publicKey);
const x = b64uToBytes(pub.x!);
const y = b64uToBytes(pub.y!);
// ---- SubjectPublicKeyInfo ----
const spki = seq(
// AlgorithmIdentifier: id-ecPublicKey(1.2.840.10045.2.1) + prime256v1(1.2.840.10045.3.1.7)
seq(oid('1.2.840.10045.2.1'), oid('1.2.840.10045.3.1.7')),
// BIT STRING 里是 0x04 || X || Y(未压缩点)
bitStr(concat(new Uint8Array([0x04]), x, y)),
);
// ---- SAN 扩展(2.5.29.17)----
//
// ★ 这里有个**极容易多套一层 SEQUENCE** 的坑(实测踩过,openssl 报
// `wrong tag ... Field=object, Type=X509_ATTRIBUTE`)。逐字节对齐
// `openssl req -new` 的产物后,正确结构是:
//
// attributes [0] { -- a0 2e
// SEQUENCE { -- 30 2c Attribute
// OID 1.2.840.113549.1.9.14 -- 06 09… Extension Request
// SET { -- 31 1f
// SEQUENCE { -- 30 1d Extensions
// SEQUENCE { -- 30 1b SAN 扩展本身
// OID 2.5.29.17,
// OCTET STRING { SEQUENCE OF GeneralName }
// }
// }
// }
// }
// }
//
// 关键点:`[0]` 里**直接**就是 Attribute 的 SEQUENCE —— 写成
// `rawTag(0xa0, seq(seq(...)))` 会多一层,OpenSSL 就会拿
// SAN 的 OID 去当 attribute type 查表,于是一路 `nested asn1 err`。
const sanNames = domains.map((d) => rawTag(0x82, enc.encode(d))); // [2] dNSName
const sanExt = seq(
oid('2.5.29.17'),
// extnValue 里包的才是真正的 SEQUENCE OF GeneralName
octetStr(seq(...sanNames)),
);
const extReq = rawTag(0xa0, seq(oid('1.2.840.113549.1.9.14'), setOf(seq(sanExt))));
// ---- CertificationRequestInfo ----
const cri = seq(
int(0), // version
// Name: 相对专有名词 [SET { SEQUENCE { OID commonName, UTF8String } }]
seq(setOf(seq(oid('2.5.4.3'), rawTag(0x0c, enc.encode(cn))))),
spki,
extReq,
);
// ---- 签名 ----
// ★ TS 5.7 起 Uint8Array 是泛型(Uint8Array<ArrayBufferLike>),而 subtle.sign
// 要的是 BufferSource 里收窄过的 ArrayBufferView<ArrayBuffer>。
// 复制成一份确定 backing 的 buffer 即可消除 SharedArrayBuffer 的可能性。
const criBytes = new Uint8Array(cri).buffer as ArrayBuffer;
const sig = await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, keyPair.privateKey, criBytes);
// ★ CSR 的签名要 **DER 编码的 ECDSA-Sig-Value**(SEQUENCE { r, s }),
// 与 JWS 的原始 r||s 不同 —— 这里必须转。
const r = trimLeadingZeros(new Uint8Array(sig).slice(0, 32));
const s = trimLeadingZeros(new Uint8Array(sig).slice(32));
const derSig = seq(intBytes(r), intBytes(s));
const csr = seq(cri, seq(oid('1.2.840.10045.4.3.2')), bitStr(derSig));
return csr.buffer as ArrayBuffer;
}
/**
* 供**离线自测**用的 CSR 导出(见 `tools/selftest-csr.mjs`)。
*
* ★ 为什么值得单独开一个口子:CSR 是**手写 DER**(不引 asn1.js / pkijs,
* 理由见上面 makeCsr 的注释)。少一个长度字节、多套一层 SEQUENCE,
* CA 那边往往只回一句极含糊的错 —— LiteSSL 实测直接 500
* `The server experienced an internal error`,完全指不到点上。
* 离线跑一遍 `openssl req -inform DER -verify`,比拿真订单去试快得多,
* 也不会白烧 CA 的订单配额。
*/
export async function makeCsrForTest(keyPair: CryptoKeyPair, domains: string[]): Promise<ArrayBuffer> {
return makeCsrImpl(keyPair, domains);
}
// ---- 极简 DER 编码器 ----
function concat(...arrs: Uint8Array[]): Uint8Array {
const total = arrs.reduce((n, a) => n + a.length, 0);
const out = new Uint8Array(total);
let off = 0;
for (const a of arrs) {
out.set(a, off);
off += a.length;
}
return out;
}
/** 按 DER 长度规则包装:短形式(<128)或长形式 */
function wrap(tag: number, content: Uint8Array): Uint8Array {
const len = content.length;
let lenBytes: Uint8Array;
if (len < 0x80) lenBytes = new Uint8Array([len]);
else if (len < 0x100) lenBytes = new Uint8Array([0x81, len]);
else if (len < 0x10000) lenBytes = new Uint8Array([0x82, len >> 8, len & 0xff]);
else lenBytes = new Uint8Array([0x83, (len >> 16) & 0xff, (len >> 8) & 0xff, len & 0xff]);
return concat(new Uint8Array([tag]), lenBytes, content);
}
const seq = (...parts: Uint8Array[]) => wrap(0x30, concat(...parts));
const setOf = (...parts: Uint8Array[]) => wrap(0x31, concat(...parts));
const octetStr = (b: Uint8Array) => wrap(0x04, b);
const bitStr = (b: Uint8Array) => wrap(0x03, concat(new Uint8Array([0x00]), b)); // 0 unused bits
const rawTag = (tag: number, b: Uint8Array) => wrap(tag, b);
/** OID 编码:第一字节 = 40*a+b,其余按 7 位变长 */
function oid(dotted: string): Uint8Array {
const parts = dotted.split('.').map(Number);
const body: number[] = [40 * parts[0] + parts[1]];
for (const v of parts.slice(2)) {
const stack: number[] = [v & 0x7f];
let x = v >> 7;
while (x > 0) {
stack.unshift((x & 0x7f) | 0x80);
x >>= 7;
}
body.push(...stack);
}
return wrap(0x06, new Uint8Array(body));
}
/** INTEGER:正数,最高位为 1 时要补 0x00 前缀 */
function intBytes(bytes: Uint8Array): Uint8Array {
const body = bytes[0] & 0x80 ? concat(new Uint8Array([0x00]), bytes) : bytes;
return wrap(0x02, body);
}
function int(v: number): Uint8Array {
return intBytes(new Uint8Array([v]));
}
function trimLeadingZeros(b: Uint8Array): Uint8Array {
let i = 0;
while (i < b.length - 1 && b[i] === 0) i++;
return b.slice(i);
}
function b64uToBytes(s: string): Uint8Array {
const b64 = s.replace(/-/g, '+').replace(/_/g, '/');
const bin = atob(b64 + '='.repeat((4 - (b64.length % 4)) % 4));
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}