71 lines
3.3 KiB
JavaScript
71 lines
3.3 KiB
JavaScript
/**
|
||||
|
|
* CSR 离线自测 —— 手写 DER 的 CSR 到底合不合法。
|
|||
|
|
*
|
|||
|
|
* 为什么必须有(2026-10-06 踩坑):
|
|||
|
|
* LiteSSL 的 `finalize` 对着一张有问题的 CSR 只回了
|
|||
|
|
* `500 {"type":"urn:ietf:params:acme:error:serverInternal",
|
|||
|
|
* "detail":"The server experienced an internal error"}`
|
|||
|
|
* —— 完全指不到「CSR 结构错了」这个方向。而 CSR 是本项目**手写 DER**
|
|||
|
|
* 拼出来的(不引 asn1.js / pkijs),出错概率天然比用现成库高。
|
|||
|
|
* 这里用 openssl 逐项核对:签名自洽、subject CN、SAN 列表。
|
|||
|
|
*
|
|||
|
|
* 用法:
|
|||
|
|
* node tools/selftest-csr.mjs # 默认签 t-t.live + *.t-t.live
|
|||
|
|
* node tools/selftest-csr.mjs a.com b.com
|
|||
|
|
*/
|
|||
|
|
import fs from 'node:fs';
|
|||
|
|
import os from 'node:os';
|
|||
|
|
import path from 'node:path';
|
|||
|
|
import { execFileSync } from 'node:child_process';
|
|||
|
|
import { createRequire } from 'node:module';
|
|||
|
|
import { fileURLToPath } from 'node:url';
|
|||
|
|
|
|||
|
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|||
|
|
const require = createRequire(import.meta.url);
|
|||
|
|
const { makeCsrForTest } = require(path.resolve(here, '../.selftest-ssl/lib/acme.js'));
|
|||
|
|
|
|||
|
|
const domains = process.argv.slice(2).length ? process.argv.slice(2) : ['t-t.live', '*.t-t.live'];
|
|||
|
|
|
|||
|
|
const kp = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']);
|
|||
|
|
const der = await makeCsrForTest(kp, domains);
|
|||
|
|
|
|||
|
|
const out = path.join(os.tmpdir(), `csr-selftest-${Date.now()}.der`);
|
|||
|
|
fs.writeFileSync(out, Buffer.from(der));
|
|||
|
|
console.log(`生成 CSR:domains=${domains.join(', ')} DER ${der.byteLength} 字节`);
|
|||
|
|
console.log(`临时文件:${out}\n`);
|
|||
|
|
|
|||
|
|
const run = (...args) => {
|
|||
|
|
try {
|
|||
|
|
return { ok: true, out: execFileSync('openssl', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) };
|
|||
|
|
} catch (e) {
|
|||
|
|
return { ok: false, out: `${e.stdout || ''}${e.stderr || ''}` };
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// ① 签名自洽:openssl 用 CSR 里的公钥验 CSR 里的签名
|
|||
|
|
const v = run('req', '-inform', 'DER', '-in', out, '-noout', '-verify');
|
|||
|
|
console.log('① 签名自洽:', v.ok ? '✓ ' + v.out.trim().replace(/^.*?:\s*/, '') : '✗\n' + v.out);
|
|||
|
|
|
|||
|
|
// ② 结构 + subject + SAN
|
|||
|
|
const t = run('req', '-inform', 'DER', '-in', out, '-noout', '-text');
|
|||
|
|
if (!t.ok) {
|
|||
|
|
console.log('② ★ 结构解析失败(这就是 CA 报 500 的原因):\n' + t.out);
|
|||
|
|
process.exit(1);
|
|||
|
|
}
|
|||
|
|
const subject = (t.out.match(/Subject:\s*(.*)/) || [])[1] || '';
|
|||
|
|
const sanBlock = (t.out.match(/X509v3 Subject Alternative Name:[\s\S]*?\n\s{8}([^\n]+)/) || [])[1] || '';
|
|||
|
|
console.log('② subject =', subject.trim());
|
|||
|
|
console.log(' SAN =', sanBlock.trim());
|
|||
|
|
console.log(' 签名算法 =', ((t.out.match(/Signature Algorithm:\s*(.*)/) || [])[1] || '').trim());
|
|||
|
|
console.log(' 公钥 =', ((t.out.match(/Public Key Algorithm:\s*(.*)/) || [])[1] || '').trim(),
|
|||
|
|
((t.out.match(/NIST CURVE:\s*(.*)/) || [])[1] || '').trim());
|
|||
|
|
|
|||
|
|
// ③ 逐条比对域名(顺序 + 内容都要一致)
|
|||
|
|
const got = sanBlock.split(',').map((s) => s.trim().replace(/^DNS:/, '')).filter(Boolean);
|
|||
|
|
const want = domains.slice();
|
|||
|
|
const same = got.length === want.length && want.every((d, i) => got[i] === d);
|
|||
|
|
console.log('③ SAN 与请求一致:', same ? '✓' : `✗ 期望 [${want}] 实得 [${got}]`);
|
|||
|
|
|
|||
|
|
fs.unlinkSync(out);
|
|||
|
|
console.log('\n结论:', v.ok && same ? 'CSR 合法 ✓' : '★ CSR 有问题,别拿去打 CA');
|
|||
|
|
process.exit(v.ok && same ? 0 : 1);
|