2026-10-06 16:59:37 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 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)));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 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;
|
|
|
|
|
|
|
2026-10-06 18:28:55 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* ★ 账户私钥的 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;
|
|
|
|
|
|
|
2026-10-06 16:59:37 +08:00
|
|
|
|
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 */
|
|
|
|
|
|
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 = this.account.jwk;
|
|
|
|
|
|
return base;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-06 18:28:55 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 取(并缓存)账户签名密钥 —— 见 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-06 16:59:37 +08:00
|
|
|
|
private async signJws(protectedHeader: Record<string, unknown>, payload: unknown): Promise<string> {
|
2026-10-06 18:28:55 +08:00
|
|
|
|
const key = await this.importSigningKey();
|
2026-10-06 16:59:37 +08:00
|
|
|
|
const signingInput = `${b64uJson(protectedHeader)}.${b64uJson(payload)}`;
|
|
|
|
|
|
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: b64uJson(payload), signature: b64u(sig) });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 发一个 POST;自动带 nonce、自动在 badNonce 时重试 */
|
|
|
|
|
|
private async post(url: string, payload: unknown, opts: { useJwk?: boolean; retries?: number } = {}): 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', 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 */
|
|
|
|
|
|
private async postAsGet(url: string): Promise<Response> {
|
|
|
|
|
|
return this.post(url, ''); // payload 为空字符串(不是 null —— null 是「要服务端删掉字段」)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------- 账户
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 注册(或找回)账户。
|
|
|
|
|
|
* @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 本身
|
|
|
|
|
|
const innerProtected = b64uJson({ alg: 'HS256', kid: eab.kid, url: d.newAccount });
|
|
|
|
|
|
const innerPayload = b64uJson(this.account.jwk);
|
|
|
|
|
|
const rawKey = Uint8Array.from(atob(eab.hmacKeyB64.replace(/-/g, '+').replace(/_/g, '/')), (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}`);
|
|
|
|
|
|
|
|
|
|
|
|
// ② 逐个授权:写 DNS → 通知就绪 → 轮询
|
|
|
|
|
|
const written: { name: string; value: 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 });
|
|
|
|
|
|
|
|
|
|
|
|
// ③ 等 DNS 传播 —— 不给自己留这个时间,验证会一直 pending 到超时
|
|
|
|
|
|
const wait = opts.waitSeconds ?? 30;
|
|
|
|
|
|
if (wait > 0) {
|
|
|
|
|
|
this.log(`等 ${wait}s 让 DNS 生效…`);
|
|
|
|
|
|
await sleep(wait * 1000);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ④ 通知 CA 开始验证
|
|
|
|
|
|
await this.post(chal.url, {});
|
|
|
|
|
|
await this.pollAuthz(authzUrl, 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 makeCsr(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 certRes = await this.postAsGet(certUrl);
|
|
|
|
|
|
const certPem = await certRes.text();
|
|
|
|
|
|
|
|
|
|
|
|
// ★ 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}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 makeCsr(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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- 极简 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;
|
|
|
|
|
|
}
|