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),
保留三条纯监控告警。
This commit is contained in:
1 parent
1fa639e630
commit
b821b78718
20 files changed
+1662
-177
No files matched your search
+227
-23
@@ -92,6 +92,51 @@ 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);
|
||||
@@ -177,11 +222,17 @@ export class AcmeClient {
|
||||
return n;
|
||||
}
|
||||
|
||||
/** 有 kid → 用 kid;没有(注册阶段)→ 用 jwk */
|
||||
/**
|
||||
* 有 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 = this.account.jwk;
|
||||
else base.jwk = publicJwk(this.account.jwk);
|
||||
return base;
|
||||
}
|
||||
|
||||
@@ -208,18 +259,28 @@ export class AcmeClient {
|
||||
|
||||
private async signJws(protectedHeader: Record<string, unknown>, payload: unknown): Promise<string> {
|
||||
const key = await this.importSigningKey();
|
||||
const signingInput = `${b64uJson(protectedHeader)}.${b64uJson(payload)}`;
|
||||
// ★ 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: b64uJson(payload), signature: b64u(sig) });
|
||||
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 } = {}): Promise<Response> {
|
||||
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;
|
||||
@@ -233,7 +294,12 @@ export class AcmeClient {
|
||||
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' },
|
||||
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');
|
||||
@@ -259,9 +325,38 @@ export class AcmeClient {
|
||||
}
|
||||
}
|
||||
|
||||
/** POST-as-GET(RFC 8555 §6.3):读资源也要用 POST 签名,不能直接 GET */
|
||||
private async postAsGet(url: string): Promise<Response> {
|
||||
return this.post(url, ''); // payload 为空字符串(不是 null —— null 是「要服务端删掉字段」)
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- 账户
|
||||
@@ -279,10 +374,17 @@ export class AcmeClient {
|
||||
};
|
||||
|
||||
if (eab) {
|
||||
// ★ 内层 JWS:protected 只放 alg/kid/url,payload 是账户 JWK 本身
|
||||
// ★ 内层 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(this.account.jwk);
|
||||
const rawKey = Uint8Array.from(atob(eab.hmacKeyB64.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0));
|
||||
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 = {
|
||||
@@ -331,8 +433,17 @@ export class AcmeClient {
|
||||
const orderUrl = orderRes.headers.get('Location') || '';
|
||||
this.log(`订单已创建(${domains.join(', ')}),状态 ${order.status}`);
|
||||
|
||||
// ② 逐个授权:写 DNS → 通知就绪 → 轮询
|
||||
// ② 逐个授权:读状态 → 算 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 {
|
||||
@@ -356,32 +467,77 @@ export class AcmeClient {
|
||||
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 传播 —— 不给自己留这个时间,验证会一直 pending 到超时
|
||||
// ③ 等 DNS 传播 —— **所有 TXT 写完后统一等一次**
|
||||
// 不给自己留这个时间,验证会一直 pending 到超时。
|
||||
if (pending.length) {
|
||||
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);
|
||||
}
|
||||
|
||||
// ④ 逐个「通知 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 makeCsr(certKeyPair, domains);
|
||||
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 certRes = await this.postAsGet(certUrl);
|
||||
const certPem = await certRes.text();
|
||||
const certPem = await this.downloadCert(certUrl);
|
||||
|
||||
// ★ exportKey('pkcs8') 的 TS 重载返回 ArrayBuffer | JsonWebKey(因为 format 是联合),
|
||||
// 这里 format 已确定是 pkcs8,用 exportDer 包装断言回 ArrayBuffer。
|
||||
@@ -402,8 +558,42 @@ export class AcmeClient {
|
||||
}
|
||||
}
|
||||
|
||||
private async pollAuthz(url: string, timeoutMs: number): Promise<void> {
|
||||
/**
|
||||
* 等订单进入 `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;
|
||||
@@ -490,7 +680,7 @@ export async function newAccountKey(): Promise<JsonWebKey> {
|
||||
* signature BIT STRING
|
||||
* }
|
||||
*/
|
||||
async function makeCsr(keyPair: CryptoKeyPair, domains: string[]): Promise<ArrayBuffer> {
|
||||
async function makeCsrImpl(keyPair: CryptoKeyPair, domains: string[]): Promise<ArrayBuffer> {
|
||||
const cn = domains[0];
|
||||
const pub = await exportJwk(keyPair.publicKey);
|
||||
const x = b64uToBytes(pub.x!);
|
||||
@@ -560,6 +750,20 @@ async function makeCsr(keyPair: CryptoKeyPair, domains: string[]): Promise<Array
|
||||
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 {
|
||||
|
||||
Reference in new issue
Block a user