/** * 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 => crypto.subtle.exportKey('jwk', k) as Promise; const exportDer = (k: CryptoKey, format: 'pkcs8' | 'spki' | 'raw'): Promise => crypto.subtle.exportKey(format, k) as Promise; 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)[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 | null = null; constructor( private readonly directoryUrl: string, private readonly account: { jwk: JsonWebKey; kid: string }, log?: AcmeLogger, ) { this.log = log || (() => {}); } // ---------------------------------------------------------- 底层请求 private async directory(): Promise { 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 { const d = (await this.directory()) as Directory & { meta?: { externalAccountRequired?: boolean } }; return d.meta?.externalAccountRequired === true; } private async takeNonce(): Promise { 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 { const base: Record = { 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 { if (!this.signingKey) { // 失败时清掉缓存,否则一次网络/参数抖动会被永久缓存成 reject this.signingKey = (crypto.subtle.importKey( 'jwk', this.account.jwk, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign'], ) as Promise).catch((e) => { this.signingKey = null; throw e; }); } return this.signingKey; } private async signJws(protectedHeader: Record, payload: unknown): Promise { 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 { 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 { 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 { 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 { const d = await this.directory(); const payload: Record = { 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` * @param clearTxt 删 TXT 记录(失败不影响签发,只记日志) * @param opts.waitSeconds 写完后等多久让 DNS 生效(默认 30s,DNSPod 一般 10s 内) */ async issueDns01( domains: string[], setTxt: (name: string, value: string) => Promise, clearTxt: (name: string, value: string) => Promise, opts: { waitSeconds?: number; timeoutMs?: number } = {}, ): Promise { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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),而 subtle.sign // 要的是 BufferSource 里收窄过的 ArrayBufferView。 // 复制成一份确定 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 { 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; }