Files
blog/blog-admin/tools/selftest-acme.mjs
T
zqlit 43dbc8b75f feat(ssl): ACME 自动签发与自动续期,实现证书全生命周期闭环
证书管家此前只做「探针」(查剩余天数),现补齐签发+部署两个环节,
参照 certimate(MIT)的 DNS-01 流程自行实现,不再依赖闭源 certd。

新增(纯 WebCrypto,零 npm 依赖):
- lib/acme.ts        ACME v2 客户端:ES256 JWS(原始 r||s)、RFC7638
                     thumbprint、EAB、badNonce 重试、DNS-01、手写 DER CSR
- lib/dnsprovider.ts DNS-01 适配:DNSPod(TC3-HMAC-SHA256)、Cloudflare
- lib/deployer.ts    部署适配:多吉云 CDN、1Panel 站点(幂等换证书)
- lib/certissue.ts   编排:探针判剩余天数 → 注册/复用账户 → 签发 → 落库
                     → 逐目标部署;RENEW_BEFORE_DAYS=30
- routes/ssl.ts      新增 POST /ssl/issue、GET /ssl/renew-check、
                     POST /ssl/selfcheck(环境自检,只读不签发)
- index.ts + cron    每日 04:10 自动续期检查;cpu_ms 提到 60s

与 certimate 的差异:certimate 每个 workflow 每天无条件重跑,
这里改为先探针查剩余天数、低于阈值才签,省 CA 限速额度。

实测修正(易误判,勿回退):
- 多吉云 bind 参数是 {id, domain},非 {cert_id}(用假 id 对照实验确认:
  cert_id 回「域名不存在」= 参数被无视)
- 多吉云上传私钥字段是 private;列域名用 /cdn/domain/list.json
- 1Panel 必须用 /api/v2/(v1 返回 HTTP 200 但正文是 HTML 停用页)
- 1Panel HTTPS 配置字段是 SSL(大写),写错会导致每次续期都重绑
- LiteSSL ACME 目录须带 /v2:acme.trustasia.com/acme/v2/directory

测试:selftest-acme 16/16(CSR 过 openssl 验签、JWS 过 Node crypto 验签)、
selftest-deploy 18/18、selftest:ssl 117/117、UI 全过、tsc 干净
2026-10-06 16:59:37 +08:00

159 lines
8.5 KiB
JavaScript
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 客户端离线自测 —— 不联网,只验最容易错的两块:
* ① CSR 的 DER 编码(用 Node 的 openssl 交叉验证)
* ② JWS 的 ES256 签名与 JWK thumbprint(用 Node crypto 独立复算)
*
* 跑法:node tools/selftest-acme.mjs
*
* ★ 2026-10-06 修正两处:
* · `execFileSync` 在本机沙箱里**必抛 EBUSY**(同步 spawn 一律被杀),
* 改用 `execFile` + promisify 的异步版本 —— 同一个坑在
* editor-api/test/role-perm.mjs 里也踩过一次。
* · CSR 长度断言原写 >300,实测 EC P-256 的 CSR 只有 250 字节左右
* (RSA 才上千)。改成 >200 —— 断言要卡在「结构明显不对」而不是
* 「我以为应该多大」。
*/
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import crypto from 'node:crypto';
const pexec = promisify(execFile);
let pass = 0;
const fails = [];
function t(name, cond, extra = '') {
if (cond) { pass++; console.log(' ✓ ' + name); }
else { fails.push(name + (extra ? ' → ' + extra : '')); console.log(' ✗ ' + name + (extra ? ' → ' + extra : '')); }
}
// ---- 复刻 acme.ts 里的 DER 编码器(保持一致才有意义)----
const enc = new TextEncoder();
const concat = (...a) => { const n = a.reduce((x, y) => x + y.length, 0); const o = new Uint8Array(n); let f = 0; for (const x of a) { o.set(x, f); f += x.length; } return o; };
function wrap(tag, c) {
const len = c.length; let lb;
if (len < 0x80) lb = new Uint8Array([len]);
else if (len < 0x100) lb = new Uint8Array([0x81, len]);
else if (len < 0x10000) lb = new Uint8Array([0x82, len >> 8, len & 0xff]);
else lb = new Uint8Array([0x83, (len >> 16) & 0xff, (len >> 8) & 0xff, len & 0xff]);
return concat(new Uint8Array([tag]), lb, c);
}
const seq = (...p) => wrap(0x30, concat(...p));
const setOf = (...p) => wrap(0x31, concat(...p));
const octetStr = (b) => wrap(0x04, b);
const bitStr = (b) => wrap(0x03, concat(new Uint8Array([0x00]), b));
const rawTag = (t, b) => wrap(t, b);
function oid(dotted) {
const parts = dotted.split('.').map(Number);
const body = [40 * parts[0] + parts[1]];
for (const v of parts.slice(2)) { const st = [v & 0x7f]; let x = v >> 7; while (x > 0) { st.unshift((x & 0x7f) | 0x80); x >>= 7; } body.push(...st); }
return wrap(0x06, new Uint8Array(body));
}
function intBytes(b) { return wrap(0x02, b[0] & 0x80 ? concat(new Uint8Array([0]), b) : b); }
const trimZ = (b) => { let i = 0; while (i < b.length - 1 && b[i] === 0) i++; return b.slice(i); };
const b64u = (b) => Buffer.from(b).toString('base64url');
const b64uToBytes = (s) => new Uint8Array(Buffer.from(s, 'base64url'));
// 用异步 execFile —— 本机(Windows + 沙箱)同步 spawn 一律 EBUSY
function openssl(args) {
return new Promise((resolve) => {
execFile('openssl', args, { encoding: 'utf8' }, (err, stdout, stderr) => {
resolve({ err, out: (stdout || '') + (stderr || '') });
});
});
}
async function makeCsr(kp, domains) {
const cn = domains[0];
const pub = await crypto.subtle.exportKey('jwk', kp.publicKey);
const x = b64uToBytes(pub.x), y = b64uToBytes(pub.y);
const spki = seq(seq(oid('1.2.840.10045.2.1'), oid('1.2.840.10045.3.1.7')), bitStr(concat(new Uint8Array([4]), x, y)));
const sanExt = seq(oid('2.5.29.17'), octetStr(seq(...domains.map((d) => rawTag(0x82, enc.encode(d))))));
// ★ 只套三层 seq:Attribute / Extensions / SAN 扩展本身。
// 多一层(setOf(seq(seq(sanExt))))会让 openssl 报
// `wrong tag ... Field=object, Type=X509_ATTRIBUTE`。
const extReq = rawTag(0xa0, seq(oid('1.2.840.113549.1.9.14'), setOf(seq(sanExt))));
const cri = seq(wrap(0x02, new Uint8Array([0])), seq(setOf(seq(oid('2.5.4.3'), rawTag(0x0c, enc.encode(cn))))), spki, extReq);
const sig = await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, kp.privateKey, cri);
const r = trimZ(new Uint8Array(sig).slice(0, 32)), s = trimZ(new Uint8Array(sig).slice(32));
const derSig = seq(intBytes(r), intBytes(s));
return seq(cri, seq(oid('1.2.840.10045.4.3.2')), bitStr(derSig));
}
console.log('\n[1] CSR 生成与结构验证');
const kp = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']);
const domains = ['usj.cc', '*.usj.cc'];
const csr = await makeCsr(kp, domains);
const csrDer = Buffer.from(csr);
t('CSR 以 SEQUENCE 开头(0x30)', csrDer[0] === 0x30, 'got 0x' + csrDer[0].toString(16));
t('CSR 长度合理(>200 字节)', csrDer.length > 200, String(csrDer.length));
// 用 Node 自带的 crypto 交叉验证:能解析说明 DER 结构合法
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'acme-csr-'));
const csrPem = '-----BEGIN CERTIFICATE REQUEST-----\n' +
(csrDer.toString('base64').match(/.{1,64}/g) || []).join('\n') +
'\n-----END CERTIFICATE REQUEST-----\n';
const csrFile = path.join(tmp, 'test.csr');
fs.writeFileSync(csrFile, csrPem);
try {
const { stdout: out } = await pexec('openssl', ['req', '-in', csrFile, '-noout', '-text', '-verify'], {
encoding: 'utf8',
});
t('openssl 能解析并验签通过', out.includes('verify OK') || out.includes('Certificate Request'), '');
t('CSR 里含 CN=usj.cc', /CN\s*=\s*usj\.cc|commonName.*usj\.cc/s.test(out), '');
t('CSR 里含 SAN usj.cc', out.includes('usj.cc'), '');
t('CSR 里含通配符 *.usj.cc', out.includes('*.usj.cc'), '');
t('签名算法是 ecdsa-with-SHA256', /ecdsa-with-SHA256|id-ecPublicKey|prime256v1/i.test(out), '');
} catch (e) {
t('openssl 解析 CSR', false, (e.stderr || e.message || '').slice(0, 200));
}
console.log('\n[2] JWS / ES256 验证');
const accKp = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']);
const jwk = await crypto.subtle.exportKey('jwk', accKp.privateKey);
const protectedHeader = { alg: 'ES256', nonce: 'abc123', url: 'https://acme.example/new-account', jwk };
const payload = { termsOfServiceAgreed: true };
const p64 = Buffer.from(JSON.stringify(protectedHeader)).toString('base64url');
const pl64 = Buffer.from(JSON.stringify(payload)).toString('base64url');
const sig = await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, accKp.privateKey, enc.encode(p64 + '.' + pl64));
t('ES256 签名是 64 字节(raw r||s,不是 DER)', sig.byteLength === 64, String(sig.byteLength));
// 用 Node crypto 独立验签(JWK → KeyObject)
const pubKey = crypto.createPublicKey({ key: { ...jwk, d: undefined }, format: 'jwk' });
const ok = crypto.verify('sha256', Buffer.from(p64 + '.' + pl64), { key: pubKey, dsaEncoding: 'ieee-p1363' }, Buffer.from(sig));
t('Node crypto 验签通过(ieee-p1363 = 原始 r||s)', ok === true, String(ok));
console.log('\n[3] JWK thumbprint(RFC 7638)');
const canonical = JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y });
const thumb = crypto.createHash('sha256').update(canonical).digest('base64url');
t('thumbprint 是 43 字符的 base64url', /^[A-Za-z0-9_-]{43}$/.test(thumb), thumb);
t('字段顺序必须是 crv,kty,x,y', canonical.indexOf('crv') < canonical.indexOf('kty') && canonical.indexOf('kty') < canonical.indexOf('x'), canonical.slice(0, 40));
console.log('\n[4] key authorization(DNS-01)');
const token = 'test-token-abc';
const keyAuth = `${token}.${thumb}`;
const txtValue = crypto.createHash('sha256').update(keyAuth).digest('base64url');
t('TXT 值是 43 字符 base64url', /^[A-Za-z0-9_-]{43}$/.test(txtValue), txtValue);
t('★ base64url 不是标准 base64(无 +/=)', !/[+/=]/.test(txtValue), txtValue);
console.log('\n[5] PEM 输出格式');
const pkcs8 = await crypto.subtle.exportKey('pkcs8', kp.privateKey);
const pemLines = Buffer.from(pkcs8).toString('base64').match(/.{1,64}/g);
const pem = `-----BEGIN PRIVATE KEY-----\n${pemLines.join('\n')}\n-----END PRIVATE KEY-----\n`;
t('私钥 PEM 有正确的头尾', pem.startsWith('-----BEGIN PRIVATE KEY-----') && pem.includes('-----END PRIVATE KEY-----'), '');
t('PEM 每行不超过 64 字符', pemLines.every((l) => l.length <= 64), '');
try {
const k = crypto.createPrivateKey(pem);
t('Node 能加载生成的 PEM 私钥', k.asymmetricKeyType === 'ec', k.asymmetricKeyType);
} catch (e) {
t('Node 能加载生成的 PEM 私钥', false, e.message);
}
fs.rmSync(tmp, { recursive: true, force: true });
console.log('\n' + '='.repeat(56));
console.log(`通过 ${pass} / ${pass + fails.length}`);
if (fails.length) { console.log('\n失败项:'); for (const f of fails) console.log(' · ' + f); }
process.exit(fails.length ? 1 : 0);