/** * 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);