publish-apk 0.0.1-alpha.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +795 -0
- package/package.json +40 -0
- package/src/apk.js +694 -0
- package/src/cert.js +1468 -0
- package/src/cli.js +185 -0
- package/src/google.js +1884 -0
- package/src/hap.js +939 -0
- package/src/harmony-material.js +174 -0
- package/src/help.js +50 -0
- package/src/huawei-signing.js +1279 -0
- package/src/huawei.js +3295 -0
- package/src/jwt.js +161 -0
- package/src/profile.js +753 -0
- package/src/publish-apk.js +161 -0
package/src/cert.js
ADDED
|
@@ -0,0 +1,1468 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 证书 / 密钥库查看命令(`publish-apk cert`)。
|
|
3
|
+
*
|
|
4
|
+
* 定位:与具体应用市场无关的通用工具——
|
|
5
|
+
* - 读取 X.509 证书(PEM / DER),把信息逐行输出;
|
|
6
|
+
* - 读取 PKCS#12 密钥库(.p12 / .pfx,Android 的 storeFile 就是这种格式),
|
|
7
|
+
* 输出每个别名的密钥算法、证书链与全部证书字段。
|
|
8
|
+
* 证书文件必须用 --file(或环境变量 CERT_FILE)显式给出,不设默认值;
|
|
9
|
+
* 本模块不假设文件名,certpath.cer / storeFile.p12 只是常见用法里的例子。
|
|
10
|
+
*
|
|
11
|
+
* 两条输出规则:
|
|
12
|
+
* 1. 每行一条信息,形如「标签: 值」;主题 / 颁发者里的多行 DN 会合并成一行,
|
|
13
|
+
* 避免一张证书的信息散成多行、无法用 grep/awk 取用。
|
|
14
|
+
* 2. 默认只输出常用字段;加 --verbose(或 CERT_VERBOSE=1)输出全部字段,
|
|
15
|
+
* 包括 DN 的每一项、三种指纹、公钥细节、X.509 扩展与 PKCS#12 的加密参数。
|
|
16
|
+
* 证书原文(PEM/DER)与私钥内容都不打印,需要时直接读取 --file 指向的文件。
|
|
17
|
+
*
|
|
18
|
+
* 为什么用 node:crypto 的 X509Certificate:项目约定零第三方依赖,node:crypto 自 Node 15.6
|
|
19
|
+
* 起提供 X509Certificate,Node 18 已满足。它不提供签名算法与扩展解码(较新的 Node 才补了
|
|
20
|
+
* signatureAlgorithm),因此本模块自带一个只读的 DER 解析器补齐这两类信息
|
|
21
|
+
* (见 parseCertificateDer);解析 X.509 证书本身不依赖 openssl 命令,Windows / CI 环境也能用。
|
|
22
|
+
*
|
|
23
|
+
* PKCS#12 是例外:node:crypto 没有解析接口,本模块调用系统 openssl 解密
|
|
24
|
+
* (OpenSSL 1.1.1+ / LibreSSL 均可),再把解出的 PEM 交给 node:crypto 解析;
|
|
25
|
+
* 不引入 npm 依赖,也不会把私钥内容写进 stdout / stderr。
|
|
26
|
+
*
|
|
27
|
+
* 退出代码与其它命令一致:0 成功,1 参数错误或文件读取 / 解析失败。
|
|
28
|
+
*/
|
|
29
|
+
import { spawnSync } from 'node:child_process'
|
|
30
|
+
import { createHash, createPrivateKey, createPublicKey, X509Certificate } from 'node:crypto'
|
|
31
|
+
import { readFileSync } from 'node:fs'
|
|
32
|
+
import process from 'node:process'
|
|
33
|
+
|
|
34
|
+
import {
|
|
35
|
+
EXIT_OK,
|
|
36
|
+
PROGRAM,
|
|
37
|
+
UsageError,
|
|
38
|
+
isHelpToken,
|
|
39
|
+
parseOptions,
|
|
40
|
+
readEnv,
|
|
41
|
+
stdout,
|
|
42
|
+
} from './cli.js'
|
|
43
|
+
import { renderHelp } from './help.js'
|
|
44
|
+
|
|
45
|
+
/** 命令名(根帮助与示例里使用) */
|
|
46
|
+
const COMMAND_NAME = 'cert'
|
|
47
|
+
/** 证书文件或 PKCS#12 密钥库路径;必填 */
|
|
48
|
+
const OPTION_FILE = '--file'
|
|
49
|
+
/** PKCS#12 密钥库密码(只对 .p12 / .pfx 生效) */
|
|
50
|
+
const OPTION_PASSWORD = '--password'
|
|
51
|
+
/** 开关:输出全部字段(默认只输出常用字段) */
|
|
52
|
+
const OPTION_VERBOSE = '--verbose'
|
|
53
|
+
/** cert 支持的全部选项 */
|
|
54
|
+
const CERT_OPTIONS = new Set([OPTION_FILE, OPTION_PASSWORD, OPTION_VERBOSE])
|
|
55
|
+
/** cert 中的开关型选项(不带值) */
|
|
56
|
+
const CERT_FLAGS = new Set([OPTION_VERBOSE])
|
|
57
|
+
|
|
58
|
+
/** 证书文件或 PKCS#12 密钥库路径,等价于 --file(必填) */
|
|
59
|
+
const ENV_FILE = 'CERT_FILE'
|
|
60
|
+
/** PKCS#12 密钥库密码,等价于 --password */
|
|
61
|
+
const ENV_PASSWORD = 'CERT_PASSWORD'
|
|
62
|
+
/** 开关:输出全部字段,等价于 --verbose */
|
|
63
|
+
const ENV_VERBOSE = 'CERT_VERBOSE'
|
|
64
|
+
|
|
65
|
+
/** PEM 证书块;用非贪婪匹配,一张文件里可以有多张证书(证书链) */
|
|
66
|
+
const PEM_BLOCK = /-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g
|
|
67
|
+
|
|
68
|
+
// -------------------------------------------------------------- 开关型参数
|
|
69
|
+
|
|
70
|
+
/** CERT_VERBOSE 里代表「开启」的取值(大小写、首尾空白不敏感) */
|
|
71
|
+
const VERBOSE_ON_VALUES = new Set(['1', 'true', 'yes', 'on'])
|
|
72
|
+
/** CERT_VERBOSE 里代表「关闭」的取值;空串由 readEnv 归为「未设置」 */
|
|
73
|
+
const VERBOSE_OFF_VALUES = new Set(['0', 'false', 'no', 'off'])
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* --verbose 的取值来源:命令行选项优先,其次环境变量 CERT_VERBOSE。
|
|
77
|
+
*
|
|
78
|
+
* 与 huawei / google 的开关型选项一致:环境变量只认白名单,真值开启、假值关闭、
|
|
79
|
+
* 其它值报用法错误。不采用「非空即开启」,否则 CERT_VERBOSE=0 会在 CI 日志里
|
|
80
|
+
* 凭空多出全部字段,而无法识别的值被悄悄当成关闭又会丢掉排查信息。
|
|
81
|
+
*/
|
|
82
|
+
function resolveVerbose(options, env, help) {
|
|
83
|
+
if (options.get(OPTION_VERBOSE) === true) {
|
|
84
|
+
return true
|
|
85
|
+
}
|
|
86
|
+
const value = readEnv(env, ENV_VERBOSE)
|
|
87
|
+
if (value === undefined) {
|
|
88
|
+
return false
|
|
89
|
+
}
|
|
90
|
+
const normalized = value.trim().toLowerCase()
|
|
91
|
+
if (VERBOSE_ON_VALUES.has(normalized)) {
|
|
92
|
+
return true
|
|
93
|
+
}
|
|
94
|
+
if (VERBOSE_OFF_VALUES.has(normalized)) {
|
|
95
|
+
return false
|
|
96
|
+
}
|
|
97
|
+
throw new UsageError(
|
|
98
|
+
`环境变量 ${ENV_VERBOSE} 的值无效: "${value}"(开启用 1/true/yes/on,关闭用 0/false/no/off)`,
|
|
99
|
+
help(),
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ------------------------------------------------------------------ 帮助文档
|
|
104
|
+
|
|
105
|
+
/** cert 命令帮助文档:--help、无参数以外的调用失败也会复用它 */
|
|
106
|
+
function renderCertHelp() {
|
|
107
|
+
return renderHelp({
|
|
108
|
+
title: `${PROGRAM} ${COMMAND_NAME} - 查看证书 / PKCS#12 密钥库信息`,
|
|
109
|
+
usage: [`${PROGRAM} ${COMMAND_NAME} [选项]`],
|
|
110
|
+
sections: [
|
|
111
|
+
{
|
|
112
|
+
title: '选项',
|
|
113
|
+
entries: [
|
|
114
|
+
[
|
|
115
|
+
`${OPTION_FILE} <路径>`,
|
|
116
|
+
`证书文件(PEM / DER)或 PKCS#12 密钥库(.p12 / .pfx)路径,必填(可用 ${ENV_FILE} 补齐)`,
|
|
117
|
+
],
|
|
118
|
+
[
|
|
119
|
+
`${OPTION_PASSWORD} <密码>`,
|
|
120
|
+
`PKCS#12 密钥库密码;不传时先尝试空密码(可用 ${ENV_PASSWORD} 补齐)。会进入 ps / shell 历史,建议用环境变量`,
|
|
121
|
+
],
|
|
122
|
+
[
|
|
123
|
+
OPTION_VERBOSE,
|
|
124
|
+
`输出全部字段(默认只输出常用字段);命令行未给出时读取 ${ENV_VERBOSE}`,
|
|
125
|
+
],
|
|
126
|
+
['-h, --help', '显示帮助信息'],
|
|
127
|
+
],
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
title: '环境变量',
|
|
131
|
+
entries: [
|
|
132
|
+
[ENV_FILE, `证书 / PKCS#12 文件路径,等价于 ${OPTION_FILE}`],
|
|
133
|
+
[ENV_PASSWORD, `PKCS#12 密钥库密码,等价于 ${OPTION_PASSWORD}(建议用环境变量,避免进入 ps)`],
|
|
134
|
+
[ENV_VERBOSE, '输出全部字段,1/true/yes/on 开启、0/false/no/off 关闭,等价于 --verbose'],
|
|
135
|
+
],
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
title: '输出',
|
|
139
|
+
entries: [
|
|
140
|
+
['stdout', '证书或 PKCS#12 密钥库信息,每行一条「标签: 值」;--verbose 时输出全部字段'],
|
|
141
|
+
['stderr', '错误信息'],
|
|
142
|
+
],
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
title: '退出代码',
|
|
146
|
+
entries: [
|
|
147
|
+
['0', '成功'],
|
|
148
|
+
['1', '参数缺失、参数错误或文件读取 / 解析失败'],
|
|
149
|
+
],
|
|
150
|
+
},
|
|
151
|
+
],
|
|
152
|
+
notes: [
|
|
153
|
+
'示例:',
|
|
154
|
+
` ${PROGRAM} ${COMMAND_NAME} --file ./certpath.cer`,
|
|
155
|
+
` ${PROGRAM} ${COMMAND_NAME} --file ./certpath.cer --verbose`,
|
|
156
|
+
` ${ENV_FILE}=./certpath.cer ${PROGRAM} ${COMMAND_NAME}`,
|
|
157
|
+
` ${ENV_PASSWORD}=<密码> ${PROGRAM} ${COMMAND_NAME} --file ./storeFile.p12`,
|
|
158
|
+
` ${PROGRAM} ${COMMAND_NAME} --file ./storeFile.p12 --password <密码> --verbose`,
|
|
159
|
+
'',
|
|
160
|
+
'说明: 必须用 --file 或 CERT_FILE 指定文件,不提供默认文件',
|
|
161
|
+
`说明: --file 支持 PEM / DER 证书与 PKCS#12 密钥库;PKCS#12 需要系统 openssl 命令(OpenSSL 1.1.1+ / LibreSSL)`,
|
|
162
|
+
'说明: --verbose 是「输出全部字段」,不是运行日志;证书与密钥库信息属于结果,统一写 stdout',
|
|
163
|
+
'说明: 证书原文(PEM/DER)与私钥内容都不输出,需要时直接读取 --file 指向的文件',
|
|
164
|
+
'说明: PKCS#12 只使用一个密码(Android 的 storePassword;keyPassword 通常与之相同)',
|
|
165
|
+
'说明: PKCS#12 密码也可以用 CERT_PASSWORD 传入,避免密码出现在命令行参数与 shell 历史里',
|
|
166
|
+
],
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ------------------------------------------------------------------ 文件读取
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* 读取输入文件并分派到三种格式:
|
|
174
|
+
* - PEM:按 `-----BEGIN CERTIFICATE-----` 块切分,一份文件里可以有多张证书(证书链);
|
|
175
|
+
* - DER:整个文件就是一张证书的 DER 编码;
|
|
176
|
+
* - PKCS#12:Android 常见的 storeFile.p12 / .pfx,交给 readPkcs12 用 openssl 解密。
|
|
177
|
+
*
|
|
178
|
+
* 只要文件里出现 PEM 头就按 PEM 处理;有头但没有结束标记时报「格式无效」,
|
|
179
|
+
* 而不是把半截文本丢给 X509Certificate 换一个更难懂的 OpenSSL 错误。
|
|
180
|
+
*
|
|
181
|
+
* @returns {{ kind: 'certs', format: 'PEM' | 'DER', size: number, certificates: X509Certificate[] }
|
|
182
|
+
* | { kind: 'pkcs12', size: number, keys: object[], certs: object[], meta: object }}
|
|
183
|
+
*/
|
|
184
|
+
function readInput(path, password, help) {
|
|
185
|
+
const buffer = readCertificateFile(path, help)
|
|
186
|
+
const text = buffer.toString('utf8')
|
|
187
|
+
|
|
188
|
+
// 结构化格式先判定:DER P7B 的 JSON 里可能内嵌 certificate PEM 字符串,
|
|
189
|
+
// 不能先做 PEM 正则扫描,否则会把 P7B 误当成一张 PEM 证书。
|
|
190
|
+
if (looksLikePkcs12(buffer)) {
|
|
191
|
+
return {
|
|
192
|
+
kind: 'pkcs12',
|
|
193
|
+
size: buffer.length,
|
|
194
|
+
...readPkcs12(path, password, help),
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (
|
|
198
|
+
looksLikePkcs7(buffer) ||
|
|
199
|
+
text.includes('-----BEGIN PKCS7-----') ||
|
|
200
|
+
text.includes('-----BEGIN PKCS #7 SIGNED DATA-----')
|
|
201
|
+
) {
|
|
202
|
+
throw new UsageError(`这是 P7B 文件: ${path};请用 profile 命令查看`, help())
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const blocks = [...text.matchAll(PEM_BLOCK)].map((match) => match[0])
|
|
206
|
+
if (blocks.length > 0) {
|
|
207
|
+
return {
|
|
208
|
+
kind: 'certs',
|
|
209
|
+
format: 'PEM',
|
|
210
|
+
size: buffer.length,
|
|
211
|
+
certificates: blocks.map((block, index) => parseCertificate(block, path, index, help)),
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (text.includes('-----BEGIN CERTIFICATE-----')) {
|
|
215
|
+
throw new UsageError(`证书文件格式无效: ${path}(PEM 缺少 ${'-----END CERTIFICATE-----'})`, help())
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
kind: 'certs',
|
|
219
|
+
format: 'DER',
|
|
220
|
+
size: buffer.length,
|
|
221
|
+
certificates: [parseCertificate(buffer, path, 0, help)],
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** 读取文件;路径错误统一转成带帮助文档的用法错误 */
|
|
226
|
+
function readCertificateFile(path, help) {
|
|
227
|
+
try {
|
|
228
|
+
return readFileSync(path)
|
|
229
|
+
} catch (error) {
|
|
230
|
+
// 路径是使用者刚给出的输入:原样给出,便于对照 --file / CERT_FILE 的值
|
|
231
|
+
if (error.code === 'ENOENT') {
|
|
232
|
+
throw new UsageError(`文件不存在: ${path}`, help())
|
|
233
|
+
}
|
|
234
|
+
throw new UsageError(`读取文件失败: ${path}: ${error.message}`, help())
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* 判断文件是不是 PKCS#12(PFX)。
|
|
240
|
+
*
|
|
241
|
+
* PFX ::= SEQUENCE { version INTEGER (3), authSafe ContentInfo, macData ... }
|
|
242
|
+
* 所以「DER SEQUENCE 的第一个元素是 INTEGER 3」即可判定;普通 DER 证书的第一个元素
|
|
243
|
+
* 是 TBSCertificate(SEQUENCE),不会误判。PEM 文件在调用本函数前已经按 PEM 处理。
|
|
244
|
+
*/
|
|
245
|
+
function looksLikePkcs12(buffer) {
|
|
246
|
+
if (buffer.length < 4 || buffer[0] !== TAG.SEQUENCE) {
|
|
247
|
+
return false
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
const sequence = readTlv(buffer, 0)
|
|
251
|
+
const version = readTlv(buffer, sequence.start)
|
|
252
|
+
return version.tag === TAG.INTEGER && decodeInteger(buffer, version) === 3
|
|
253
|
+
} catch {
|
|
254
|
+
return false
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** 判断文件是不是 PKCS#7 SignedData(P7B):ContentInfo 的 contentType 是 signedData OID */
|
|
259
|
+
function looksLikePkcs7(buffer) {
|
|
260
|
+
if (buffer.length < 4 || buffer[0] !== TAG.SEQUENCE) {
|
|
261
|
+
return false
|
|
262
|
+
}
|
|
263
|
+
try {
|
|
264
|
+
const contentInfo = readTlv(buffer, 0)
|
|
265
|
+
const contentType = readTlv(buffer, contentInfo.start)
|
|
266
|
+
return contentType.tag === TAG.OID && decodeOid(buffer, contentType) === '1.2.840.113549.1.7.2'
|
|
267
|
+
} catch {
|
|
268
|
+
return false
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** 解析单张证书;失败时把文件与序号带进错误信息,方便在证书链里定位 */
|
|
273
|
+
function parseCertificate(data, path, index, help) {
|
|
274
|
+
try {
|
|
275
|
+
return new X509Certificate(data)
|
|
276
|
+
} catch (error) {
|
|
277
|
+
const position = index === 0 ? '' : `(第 ${index + 1} 张证书)`
|
|
278
|
+
throw new UsageError(`证书解析失败: ${path}${position}: ${error.message}`, help())
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ------------------------------------------------------------- PKCS#12 读取
|
|
283
|
+
|
|
284
|
+
/** openssl 的密码错误关键字(OpenSSL / LibreSSL 都是 Mac verify error) */
|
|
285
|
+
const PASSWORD_ERROR = /mac verify|invalid password/i
|
|
286
|
+
/** openssl 提示需要 legacy provider 的关键字(老 keystore 常用 RC2 / 3DES) */
|
|
287
|
+
const LEGACY_ERROR = /legacy|unsupported|rc2|provider/i
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* 用系统 openssl 解密 PKCS#12,并解析成 keys / certs / meta。
|
|
291
|
+
*
|
|
292
|
+
* 为什么调用外部命令:node:crypto 没有 PKCS#12 解析接口;自己实现 PBES2 / PBKDF2 /
|
|
293
|
+
* 3DES / RC2 的工作量和出错面都远大于调用系统 openssl。密码通过 stdin 传给 openssl
|
|
294
|
+
* (-passin stdin),不进入 argv;-nodes 解出的私钥只在本进程内存里用于识别算法,
|
|
295
|
+
* 绝不写入 stdout / stderr。
|
|
296
|
+
*
|
|
297
|
+
* 不传密码时先按空密码尝试:空密码的 keystore 很常见,直接可用;失败且是 MAC 校验错误
|
|
298
|
+
* 时才提示需要密码。
|
|
299
|
+
*
|
|
300
|
+
* @returns {{ meta: object, keys: object[], certs: object[] }}
|
|
301
|
+
*/
|
|
302
|
+
function readPkcs12(path, password, help) {
|
|
303
|
+
const secret = password ?? ''
|
|
304
|
+
if (/[\r\n\0]/.test(secret)) {
|
|
305
|
+
throw new UsageError('PKCS#12 密码不能包含换行或 NUL 字符', help())
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
let result = runOpenSslPkcs12(path, secret)
|
|
309
|
+
if (result.status !== 0 && LEGACY_ERROR.test(result.stderr)) {
|
|
310
|
+
// OpenSSL 3+ 默认不加载 legacy provider;老 keystore 可能用 RC2/3DES,失败后自动重试一次
|
|
311
|
+
const retry = runOpenSslPkcs12(path, secret, ['-legacy'])
|
|
312
|
+
if (retry.status === 0) {
|
|
313
|
+
result = retry
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (result.status !== 0) {
|
|
318
|
+
if (PASSWORD_ERROR.test(result.stderr)) {
|
|
319
|
+
const missing = password === undefined
|
|
320
|
+
throw new UsageError(
|
|
321
|
+
missing
|
|
322
|
+
? `PKCS#12 密钥库需要密码: 请用 ${OPTION_PASSWORD} 或环境变量 ${ENV_PASSWORD}`
|
|
323
|
+
: 'PKCS#12 密码错误(或文件已损坏)',
|
|
324
|
+
help(),
|
|
325
|
+
)
|
|
326
|
+
}
|
|
327
|
+
throw new UsageError(`解析 PKCS#12 失败: ${summarizeOpenSslError(result.stderr)}`, help())
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
try {
|
|
331
|
+
return parsePkcs12Output(result.stdout, result.stderr)
|
|
332
|
+
} catch (error) {
|
|
333
|
+
throw new UsageError(`解析 PKCS#12 内容失败: ${error.message}`, help())
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** 执行一次 openssl pkcs12;密码只通过 stdin 传入,不出现在 argv */
|
|
338
|
+
function runOpenSslPkcs12(path, password, extraArgs = []) {
|
|
339
|
+
const args = ['pkcs12', '-in', path, '-nodes', '-info', '-passin', 'stdin', ...extraArgs]
|
|
340
|
+
// 密码已经走 stdin,子进程不需要也不应该再继承 CERT_PASSWORD
|
|
341
|
+
const env = { ...process.env }
|
|
342
|
+
delete env.CERT_PASSWORD
|
|
343
|
+
const result = spawnSync('openssl', args, {
|
|
344
|
+
input: `${password}\n`,
|
|
345
|
+
encoding: 'utf8',
|
|
346
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
347
|
+
timeout: 30_000,
|
|
348
|
+
env,
|
|
349
|
+
})
|
|
350
|
+
if (result.error) {
|
|
351
|
+
if (result.error.code === 'ENOENT') {
|
|
352
|
+
throw new Error(
|
|
353
|
+
'未找到 openssl 命令:读取 PKCS#12 需要系统安装 OpenSSL(macOS / Linux 通常自带,Windows 可用 Git Bash 自带的 openssl)',
|
|
354
|
+
)
|
|
355
|
+
}
|
|
356
|
+
throw new Error(`执行 openssl 失败: ${result.error.message}`)
|
|
357
|
+
}
|
|
358
|
+
if (result.signal) {
|
|
359
|
+
throw new Error(`执行 openssl 被信号 ${result.signal} 终止`)
|
|
360
|
+
}
|
|
361
|
+
return result
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** 把 openssl 的错误压缩成一行,避免把整段 provider 日志打到终端 */
|
|
365
|
+
function summarizeOpenSslError(stderr) {
|
|
366
|
+
const lines = String(stderr)
|
|
367
|
+
.split(/\r?\n/)
|
|
368
|
+
.map((item) => item.trim())
|
|
369
|
+
.filter((item) => item !== '')
|
|
370
|
+
return lines[0] ?? '未知错误'
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* 解析 `openssl pkcs12 -nodes -info` 的输出。
|
|
375
|
+
*
|
|
376
|
+
* stdout 里是 bag 属性 + PEM 证书 + PEM 私钥;stderr 里是 MAC 与加密参数。
|
|
377
|
+
* 只保留别名、localKeyID、KeyObject 与 X509Certificate,私钥 PEM 用完即丢弃。
|
|
378
|
+
*
|
|
379
|
+
* @returns {{ meta: object, keys: object[], certs: object[] }}
|
|
380
|
+
*/
|
|
381
|
+
function parsePkcs12Output(stdout, stderr) {
|
|
382
|
+
const errorText = String(stderr)
|
|
383
|
+
const macFromLine = /^MAC:\s*(.+)$/m.exec(errorText)?.[1]?.trim()
|
|
384
|
+
const macIteration = /^MAC Iteration\s+(\d+)\s*$/m.exec(errorText)?.[1]
|
|
385
|
+
const meta = {
|
|
386
|
+
// OpenSSL 3/4:`MAC: sha256, Iteration 2048`;LibreSSL:`MAC Iteration 2048`
|
|
387
|
+
mac: macFromLine ?? (macIteration === undefined ? undefined : `Iteration ${macIteration}`),
|
|
388
|
+
macVerified: /^MAC verified OK\s*$/m.test(errorText),
|
|
389
|
+
macLength: /^MAC length:\s*(.+)$/m.exec(errorText)?.[1]?.trim(),
|
|
390
|
+
contentEncryption: cleanOpenSslDetail(/^PKCS7 Encrypted data:\s*(.+)$/m.exec(errorText)?.[1]),
|
|
391
|
+
keyEncryption: cleanOpenSslDetail(/^Shrouded Keybag:\s*(.+)$/m.exec(errorText)?.[1]),
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const keys = []
|
|
395
|
+
const certs = []
|
|
396
|
+
let alias
|
|
397
|
+
let localKeyId
|
|
398
|
+
let pem = null
|
|
399
|
+
let pemType = null
|
|
400
|
+
|
|
401
|
+
for (const rawLine of String(stdout).split(/\r?\n/)) {
|
|
402
|
+
const lineText = rawLine.trimEnd()
|
|
403
|
+
if (pem !== null) {
|
|
404
|
+
pem.push(lineText)
|
|
405
|
+
const end = lineText.trim()
|
|
406
|
+
if (
|
|
407
|
+
(pemType === 'cert' && end === '-----END CERTIFICATE-----') ||
|
|
408
|
+
(pemType === 'key' && /^-----END .*PRIVATE KEY-----$/.test(end))
|
|
409
|
+
) {
|
|
410
|
+
const block = pem.join('\n')
|
|
411
|
+
if (pemType === 'cert') {
|
|
412
|
+
certs.push({ alias, localKeyId, certificate: new X509Certificate(block) })
|
|
413
|
+
} else {
|
|
414
|
+
let key
|
|
415
|
+
try {
|
|
416
|
+
key = createPrivateKey(block)
|
|
417
|
+
} catch {
|
|
418
|
+
// 解出来的私钥无法识别时不终止整个命令:证书信息仍然可用
|
|
419
|
+
key = null
|
|
420
|
+
}
|
|
421
|
+
keys.push({ alias, localKeyId, key })
|
|
422
|
+
}
|
|
423
|
+
pem = null
|
|
424
|
+
pemType = null
|
|
425
|
+
}
|
|
426
|
+
continue
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (/^Bag Attributes(?:: .*)?$/.test(lineText.trim())) {
|
|
430
|
+
// 包括 OpenSSL 的 `Bag Attributes: <No Attributes>`:链证书经常没有 bag 属性,
|
|
431
|
+
// 不能沿用上一张证书的别名 / localKeyID;后续按签发关系归组
|
|
432
|
+
alias = undefined
|
|
433
|
+
localKeyId = undefined
|
|
434
|
+
continue
|
|
435
|
+
}
|
|
436
|
+
const friendlyName = /^\s*friendlyName:\s*(.*)$/.exec(lineText)
|
|
437
|
+
if (friendlyName !== null) {
|
|
438
|
+
alias = friendlyName[1].trim() || undefined
|
|
439
|
+
continue
|
|
440
|
+
}
|
|
441
|
+
const localKey = /^\s*localKeyID:\s*(.*)$/.exec(lineText)
|
|
442
|
+
if (localKey !== null) {
|
|
443
|
+
localKeyId = normalizeLocalKeyId(localKey[1])
|
|
444
|
+
continue
|
|
445
|
+
}
|
|
446
|
+
if (lineText.trim() === '-----BEGIN CERTIFICATE-----') {
|
|
447
|
+
pem = [lineText.trim()]
|
|
448
|
+
pemType = 'cert'
|
|
449
|
+
continue
|
|
450
|
+
}
|
|
451
|
+
if (/^-----BEGIN .*PRIVATE KEY-----$/.test(lineText.trim())) {
|
|
452
|
+
pem = [lineText.trim()]
|
|
453
|
+
pemType = 'key'
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (pem !== null) {
|
|
458
|
+
throw new Error('openssl 输出中的 PEM 块不完整')
|
|
459
|
+
}
|
|
460
|
+
return { meta, keys, certs }
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** localKeyID 的字节串统一成大写、冒号分隔的形式(如 48 E9 → 48:E9) */
|
|
464
|
+
function normalizeLocalKeyId(value) {
|
|
465
|
+
const bytes = value.trim().split(/\s+/).filter((item) => item !== '')
|
|
466
|
+
return bytes.length === 0 ? undefined : bytes.map((byte) => byte.toUpperCase()).join(':')
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* 清理 openssl `-info` 的加密参数。
|
|
471
|
+
*
|
|
472
|
+
* LibreSSL 会把 `PKCS7 Encrypted data:` 与下一行的 `Certificate bag` 挤在一起,
|
|
473
|
+
* 这不是算法名,丢掉;空值也丢掉,避免输出「私钥加密: 」这种噪音。
|
|
474
|
+
*/
|
|
475
|
+
function cleanOpenSslDetail(value) {
|
|
476
|
+
const detail = value?.trim()
|
|
477
|
+
if (detail === undefined || detail === '' || detail === 'Certificate bag') {
|
|
478
|
+
return undefined
|
|
479
|
+
}
|
|
480
|
+
return detail
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// ------------------------------------------------------------------ 输出文本
|
|
484
|
+
|
|
485
|
+
/** 组装成「标签: 值」的一行;值里的换行统一压成空格,保证一条信息只占一行 */
|
|
486
|
+
function line(label, value) {
|
|
487
|
+
return `${label}: ${String(value).replace(/[\r\n]+/g, ' ')}`
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** 把主题 / 颁发者的多行 DN 合并成一行 */
|
|
491
|
+
function oneLineName(name) {
|
|
492
|
+
return name.split('\n').map((item) => item.trim()).filter((item) => item !== '').join(', ')
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** 日期转 UTC ISO 8601;Date 非法时退回证书里的原始字符串 */
|
|
496
|
+
function isoDate(date, fallback) {
|
|
497
|
+
return Number.isNaN(date.getTime()) ? fallback : date.toISOString()
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** 两个日期相差的天数(向上取整,今天之内算 1 天) */
|
|
501
|
+
function daysBetween(from, to) {
|
|
502
|
+
return Math.max(0, Math.ceil((to.getTime() - from.getTime()) / 86_400_000))
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** 证书当前状态:未生效 / 有效 / 已过期,并给出相差天数 */
|
|
506
|
+
function describeValidity(notBefore, notAfter, now = new Date()) {
|
|
507
|
+
if (now < notBefore) {
|
|
508
|
+
return `未生效(还有 ${daysBetween(now, notBefore)} 天)`
|
|
509
|
+
}
|
|
510
|
+
if (now > notAfter) {
|
|
511
|
+
return `已过期(已过期 ${daysBetween(notAfter, now)} 天)`
|
|
512
|
+
}
|
|
513
|
+
return `有效(剩余 ${daysBetween(now, notAfter)} 天)`
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** EC 曲线名 → 位数(Node 的 KeyObject 不直接给出 EC 位数) */
|
|
517
|
+
const EC_CURVE_BITS = {
|
|
518
|
+
prime256v1: 256,
|
|
519
|
+
secp256r1: 256,
|
|
520
|
+
secp256k1: 256,
|
|
521
|
+
secp384r1: 384,
|
|
522
|
+
secp521r1: 521,
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** EC 曲线名 → 常见别名(Node 用 OpenSSL 名字,这里补上 NIST 名字) */
|
|
526
|
+
const EC_CURVE_NIST = {
|
|
527
|
+
prime256v1: 'P-256',
|
|
528
|
+
secp256r1: 'P-256',
|
|
529
|
+
secp384r1: 'P-384',
|
|
530
|
+
secp521r1: 'P-521',
|
|
531
|
+
secp256k1: 'K-256',
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/** 公钥算法的一句话描述(默认字段) */
|
|
535
|
+
function describePublicKey(key) {
|
|
536
|
+
const type = key.asymmetricKeyType
|
|
537
|
+
const details = key.asymmetricKeyDetails ?? {}
|
|
538
|
+
if (type === 'rsa' || type === 'rsa-pss') {
|
|
539
|
+
const bits = details.modulusLength === undefined ? '' : `,${details.modulusLength} 位`
|
|
540
|
+
return `${type.toUpperCase()}${bits}`
|
|
541
|
+
}
|
|
542
|
+
if (type === 'ec') {
|
|
543
|
+
const curve = details.namedCurve
|
|
544
|
+
const bits = curve === undefined ? undefined : EC_CURVE_BITS[curve]
|
|
545
|
+
if (curve === undefined) {
|
|
546
|
+
return 'EC'
|
|
547
|
+
}
|
|
548
|
+
return `EC(${curve}${bits === undefined ? '' : `,${bits} 位`})`
|
|
549
|
+
}
|
|
550
|
+
if (type === 'ed25519') {
|
|
551
|
+
return 'Ed25519(256 位)'
|
|
552
|
+
}
|
|
553
|
+
if (type === 'ed448') {
|
|
554
|
+
return 'Ed448(448 位)'
|
|
555
|
+
}
|
|
556
|
+
if (type === 'dsa') {
|
|
557
|
+
return 'DSA'
|
|
558
|
+
}
|
|
559
|
+
if (type === 'dh') {
|
|
560
|
+
return 'DH'
|
|
561
|
+
}
|
|
562
|
+
return type ?? '未知'
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** 自签名判断:主题与颁发者同名且能用自身公钥验签;无法验签时如实写「未知」 */
|
|
566
|
+
function describeSelfSigned(certificate) {
|
|
567
|
+
const sameName = certificate.subject === certificate.issuer
|
|
568
|
+
if (!sameName) {
|
|
569
|
+
return '否'
|
|
570
|
+
}
|
|
571
|
+
try {
|
|
572
|
+
return certificate.verify(certificate.publicKey) ? '是' : '否'
|
|
573
|
+
} catch {
|
|
574
|
+
return '未知(无法用自身公钥验签)'
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** 公钥 SPKI 的 SHA-256 指纹(全部字段时输出) */
|
|
579
|
+
function publicKeyFingerprint(key) {
|
|
580
|
+
try {
|
|
581
|
+
const spki = key.export({ type: 'spki', format: 'der' })
|
|
582
|
+
return formatHex(createHash('sha256').update(spki).digest())
|
|
583
|
+
} catch {
|
|
584
|
+
return '未知'
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/** 字节转大写、冒号分隔的十六进制(指纹、Key Identifier 用) */
|
|
589
|
+
function formatHex(buffer) {
|
|
590
|
+
return [...buffer].map((byte) => byte.toString(16).padStart(2, '0').toUpperCase()).join(':')
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/** 序列号的十进制形式;超过 Number 安全整数范围时用 BigInt */
|
|
594
|
+
function serialDecimal(serialNumber) {
|
|
595
|
+
try {
|
|
596
|
+
return BigInt(`0x${serialNumber}`).toString(10)
|
|
597
|
+
} catch {
|
|
598
|
+
return undefined
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/** 一行的字段集合:同一张证书的默认字段与 --verbose 追加字段都从这里生成 */
|
|
603
|
+
function certificateLines(certificate, verbose, parsed) {
|
|
604
|
+
const lines = []
|
|
605
|
+
|
|
606
|
+
lines.push(line('主题', oneLineName(certificate.subject)))
|
|
607
|
+
if (verbose) {
|
|
608
|
+
for (const item of certificate.subject.split('\n')) {
|
|
609
|
+
if (item.trim() !== '') {
|
|
610
|
+
lines.push(line('主题项', item.trim()))
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
lines.push(line('颁发者', oneLineName(certificate.issuer)))
|
|
616
|
+
if (verbose) {
|
|
617
|
+
for (const item of certificate.issuer.split('\n')) {
|
|
618
|
+
if (item.trim() !== '') {
|
|
619
|
+
lines.push(line('颁发者项', item.trim()))
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
lines.push(line('生效时间', isoDate(certificate.validFromDate, certificate.validFrom)))
|
|
625
|
+
if (verbose) {
|
|
626
|
+
lines.push(line('生效时间(GMT)', certificate.validFrom))
|
|
627
|
+
}
|
|
628
|
+
lines.push(line('到期时间', isoDate(certificate.validToDate, certificate.validTo)))
|
|
629
|
+
if (verbose) {
|
|
630
|
+
lines.push(line('到期时间(GMT)', certificate.validTo))
|
|
631
|
+
}
|
|
632
|
+
lines.push(line('状态', describeValidity(certificate.validFromDate, certificate.validToDate)))
|
|
633
|
+
|
|
634
|
+
lines.push(line('版本', parsed === null ? '未知' : `${parsed.version}(X.509v${parsed.version})`))
|
|
635
|
+
const decimal = serialDecimal(certificate.serialNumber)
|
|
636
|
+
lines.push(
|
|
637
|
+
line(
|
|
638
|
+
'序列号',
|
|
639
|
+
verbose && decimal !== undefined
|
|
640
|
+
? `${certificate.serialNumber.toUpperCase()}(十进制 ${decimal})`
|
|
641
|
+
: certificate.serialNumber.toUpperCase(),
|
|
642
|
+
),
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
const signature = describeSignatureAlgorithm(certificate, parsed)
|
|
646
|
+
lines.push(line('签名算法', signature))
|
|
647
|
+
|
|
648
|
+
lines.push(line('公钥算法', describePublicKey(certificate.publicKey)))
|
|
649
|
+
if (verbose) {
|
|
650
|
+
const details = certificate.publicKey.asymmetricKeyDetails ?? {}
|
|
651
|
+
lines.push(line('公钥类型', certificate.publicKey.asymmetricKeyType ?? '未知'))
|
|
652
|
+
if (details.namedCurve !== undefined) {
|
|
653
|
+
const nist = EC_CURVE_NIST[details.namedCurve]
|
|
654
|
+
lines.push(line('公钥曲线', `${details.namedCurve}${nist === undefined ? '' : `(NIST ${nist})`}`))
|
|
655
|
+
}
|
|
656
|
+
if (details.modulusLength !== undefined) {
|
|
657
|
+
lines.push(line('公钥位数', `${details.modulusLength} 位`))
|
|
658
|
+
} else if (details.namedCurve !== undefined && EC_CURVE_BITS[details.namedCurve] !== undefined) {
|
|
659
|
+
lines.push(line('公钥位数', `${EC_CURVE_BITS[details.namedCurve]} 位`))
|
|
660
|
+
}
|
|
661
|
+
lines.push(line('公钥指纹(SHA-256)', publicKeyFingerprint(certificate.publicKey)))
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
if (verbose) {
|
|
665
|
+
lines.push(line('SHA-1 指纹', certificate.fingerprint))
|
|
666
|
+
}
|
|
667
|
+
lines.push(line('SHA-256 指纹', certificate.fingerprint256))
|
|
668
|
+
if (verbose) {
|
|
669
|
+
lines.push(line('SHA-512 指纹', certificate.fingerprint512))
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
lines.push(line('是否 CA', certificate.ca ? '是' : '否'))
|
|
673
|
+
lines.push(line('是否自签名', describeSelfSigned(certificate)))
|
|
674
|
+
|
|
675
|
+
if (verbose) {
|
|
676
|
+
lines.push(...extensionLines(parsed))
|
|
677
|
+
if (parsed === null) {
|
|
678
|
+
lines.push(line('X.509 扩展', '解析失败(仅输出证书基础字段)'))
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
return lines
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* 一张证书的完整输出行,供 profile 等命令复用。
|
|
687
|
+
*
|
|
688
|
+
* cert 命令内部已经在用 certificateLines;这里只包一层 DER 解析,避免其它模块
|
|
689
|
+
* 重复实现「证书 → 可读字段」的逻辑。
|
|
690
|
+
*/
|
|
691
|
+
export function certificateDetailLines(certificate, verbose = false) {
|
|
692
|
+
return certificateLines(certificate, verbose, parseCertificateDerSafe(certificate.raw))
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/** 一张证书的全部输出行;多张证书时用 `#N` 标题分隔 */
|
|
696
|
+
function renderCertificates(path, { format, size, certificates }, verbose) {
|
|
697
|
+
const lines = [
|
|
698
|
+
line('文件', path),
|
|
699
|
+
line('格式', format),
|
|
700
|
+
...(verbose ? [line('文件大小', `${size} 字节`)] : []),
|
|
701
|
+
line('证书数量', certificates.length),
|
|
702
|
+
]
|
|
703
|
+
|
|
704
|
+
certificates.forEach((certificate, index) => {
|
|
705
|
+
const parsed = parseCertificateDerSafe(certificate.raw)
|
|
706
|
+
if (certificates.length > 1) {
|
|
707
|
+
lines.push('', `证书 #${index + 1}:`)
|
|
708
|
+
}
|
|
709
|
+
lines.push(...certificateLines(certificate, verbose, parsed))
|
|
710
|
+
})
|
|
711
|
+
|
|
712
|
+
return lines.join('\n')
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* PKCS#12 输出:密钥库摘要 + 每个别名的密钥算法与证书。
|
|
717
|
+
*
|
|
718
|
+
* 私钥只以算法形式出现(RSA / EC / Ed25519 等),不输出任何密钥材料;
|
|
719
|
+
* 证书与密钥的对应关系优先按 localKeyID,缺失时退回 friendlyName(别名)。
|
|
720
|
+
*/
|
|
721
|
+
function renderPkcs12(path, { size, keys, certs, meta }, verbose) {
|
|
722
|
+
const lines = [
|
|
723
|
+
line('文件', path),
|
|
724
|
+
line('格式', 'PKCS#12'),
|
|
725
|
+
...(verbose ? [line('文件大小', `${size} 字节`)] : []),
|
|
726
|
+
line('密钥数量', keys.length),
|
|
727
|
+
line('证书数量', certs.length),
|
|
728
|
+
]
|
|
729
|
+
|
|
730
|
+
if (verbose) {
|
|
731
|
+
if (meta.mac !== undefined) lines.push(line('MAC', meta.mac))
|
|
732
|
+
if (meta.macVerified) lines.push(line('MAC 校验', 'OK'))
|
|
733
|
+
if (meta.macLength !== undefined) lines.push(line('MAC 长度', meta.macLength))
|
|
734
|
+
if (meta.contentEncryption !== undefined) lines.push(line('内容加密', meta.contentEncryption))
|
|
735
|
+
if (meta.keyEncryption !== undefined) lines.push(line('私钥加密', meta.keyEncryption))
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// 先按 localKeyID / 别名把证书归到密钥;剩下的按签发关系挂到证书链上
|
|
739
|
+
// (P12 里根证书通常没有 bag 属性,只能靠 issuer/subject 关系归组)
|
|
740
|
+
const groups = keys.map((key) => ({ key, certs: [] }))
|
|
741
|
+
const assigned = new Set()
|
|
742
|
+
for (const group of groups) {
|
|
743
|
+
for (const cert of certs) {
|
|
744
|
+
if (assigned.has(cert) || !belongsToKey(cert, group.key)) {
|
|
745
|
+
continue
|
|
746
|
+
}
|
|
747
|
+
group.certs.push(cert)
|
|
748
|
+
assigned.add(cert)
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
let changed = true
|
|
752
|
+
while (changed) {
|
|
753
|
+
changed = false
|
|
754
|
+
for (const cert of certs) {
|
|
755
|
+
if (assigned.has(cert)) {
|
|
756
|
+
continue
|
|
757
|
+
}
|
|
758
|
+
for (const group of groups) {
|
|
759
|
+
if (
|
|
760
|
+
group.certs.some(
|
|
761
|
+
(item) =>
|
|
762
|
+
isIssuedBy(cert.certificate, item.certificate) ||
|
|
763
|
+
isIssuedBy(item.certificate, cert.certificate),
|
|
764
|
+
)
|
|
765
|
+
) {
|
|
766
|
+
group.certs.push(cert)
|
|
767
|
+
assigned.add(cert)
|
|
768
|
+
changed = true
|
|
769
|
+
break
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
let certNumber = 0
|
|
776
|
+
for (const group of groups) {
|
|
777
|
+
const key = group.key
|
|
778
|
+
// 密钥 bag 偶尔没有 friendlyName(只有证书 bag 有),用组内证书的别名兜底
|
|
779
|
+
const alias = key.alias ?? group.certs.find((cert) => cert.alias !== undefined)?.alias
|
|
780
|
+
const localKeyId =
|
|
781
|
+
key.localKeyId ?? group.certs.find((cert) => cert.localKeyId !== undefined)?.localKeyId
|
|
782
|
+
lines.push(line('别名', alias ?? '(无)'))
|
|
783
|
+
if (verbose && localKeyId !== undefined) {
|
|
784
|
+
lines.push(line('localKeyID', localKeyId))
|
|
785
|
+
}
|
|
786
|
+
lines.push(line('密钥算法', key.key === null ? '未知(无法解析私钥)' : describePublicKey(key.key)))
|
|
787
|
+
if (verbose && key.key !== null) {
|
|
788
|
+
lines.push(...keyDetailLines(key.key))
|
|
789
|
+
}
|
|
790
|
+
for (const cert of group.certs) {
|
|
791
|
+
certNumber += 1
|
|
792
|
+
lines.push('', `证书 #${certNumber}:`)
|
|
793
|
+
lines.push(...certificateLines(cert.certificate, verbose, parseCertificateDerSafe(cert.certificate.raw)))
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
for (const cert of certs) {
|
|
798
|
+
if (assigned.has(cert)) {
|
|
799
|
+
continue
|
|
800
|
+
}
|
|
801
|
+
lines.push(line('别名', cert.alias ?? '(无)'))
|
|
802
|
+
if (verbose && cert.localKeyId !== undefined) {
|
|
803
|
+
lines.push(line('localKeyID', cert.localKeyId))
|
|
804
|
+
}
|
|
805
|
+
certNumber += 1
|
|
806
|
+
lines.push('', `证书 #${certNumber}:`)
|
|
807
|
+
lines.push(...certificateLines(cert.certificate, verbose, parseCertificateDerSafe(cert.certificate.raw)))
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
return lines.join('\n')
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/** 私钥的额外细节(--verbose);只输出算法参数,不输出密钥材料 */
|
|
814
|
+
function keyDetailLines(key) {
|
|
815
|
+
const details = key.asymmetricKeyDetails ?? {}
|
|
816
|
+
const lines = [line('密钥类型', key.asymmetricKeyType ?? '未知')]
|
|
817
|
+
if (details.namedCurve !== undefined) {
|
|
818
|
+
const nist = EC_CURVE_NIST[details.namedCurve]
|
|
819
|
+
lines.push(line('密钥曲线', `${details.namedCurve}${nist === undefined ? '' : `(NIST ${nist})`}`))
|
|
820
|
+
}
|
|
821
|
+
if (details.modulusLength !== undefined) {
|
|
822
|
+
lines.push(line('密钥位数', `${details.modulusLength} 位`))
|
|
823
|
+
} else if (key.asymmetricKeyType === 'ed25519') {
|
|
824
|
+
lines.push(line('密钥位数', '256 位'))
|
|
825
|
+
} else if (key.asymmetricKeyType === 'ed448') {
|
|
826
|
+
lines.push(line('密钥位数', '448 位'))
|
|
827
|
+
} else if (details.namedCurve !== undefined && EC_CURVE_BITS[details.namedCurve] !== undefined) {
|
|
828
|
+
lines.push(line('密钥位数', `${EC_CURVE_BITS[details.namedCurve]} 位`))
|
|
829
|
+
}
|
|
830
|
+
return lines
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/** 证书属于某个密钥:优先 localKeyID,其次别名,最后比较公钥(P12 没有 bag 属性时) */
|
|
834
|
+
function belongsToKey(cert, key) {
|
|
835
|
+
if (cert.localKeyId !== undefined && key.localKeyId !== undefined) {
|
|
836
|
+
return cert.localKeyId === key.localKeyId
|
|
837
|
+
}
|
|
838
|
+
if (cert.alias !== undefined && key.alias !== undefined && cert.alias === key.alias) {
|
|
839
|
+
return true
|
|
840
|
+
}
|
|
841
|
+
return key.key !== null && samePublicKey(cert.certificate.publicKey, key.key)
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/** 比较证书公钥与私钥对应的公钥是否一致(SPKI DER) */
|
|
845
|
+
function samePublicKey(publicKey, privateKey) {
|
|
846
|
+
try {
|
|
847
|
+
const a = publicKey.export({ type: 'spki', format: 'der' })
|
|
848
|
+
const b = createPublicKey(privateKey).export({ type: 'spki', format: 'der' })
|
|
849
|
+
return a.equals(b)
|
|
850
|
+
} catch {
|
|
851
|
+
return false
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/** cert 与 issuer 之间是否存在签发关系;无法校验时按「否」处理 */
|
|
856
|
+
function isIssuedBy(cert, issuer) {
|
|
857
|
+
try {
|
|
858
|
+
return cert.checkIssued(issuer)
|
|
859
|
+
} catch {
|
|
860
|
+
return false
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// ------------------------------------------------------------- DER 解析(补齐)
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* 只读 DER 解析器支持的标签。
|
|
868
|
+
*
|
|
869
|
+
* 不追求实现完整的 X.509:只取 node:crypto 没暴露的两类信息——签名算法 OID、X.509 扩展。
|
|
870
|
+
* 解析对象是 X509Certificate.raw(已经由 OpenSSL 校验过的合法 DER),所以这里不做
|
|
871
|
+
* 防御式递归,读到越界或长度不对就抛错,由调用方降级为「未知」。
|
|
872
|
+
*/
|
|
873
|
+
const TAG = {
|
|
874
|
+
BOOLEAN: 0x01,
|
|
875
|
+
INTEGER: 0x02,
|
|
876
|
+
BIT_STRING: 0x03,
|
|
877
|
+
OCTET_STRING: 0x04,
|
|
878
|
+
OID: 0x06,
|
|
879
|
+
SEQUENCE: 0x30,
|
|
880
|
+
CONTEXT_0: 0xa0,
|
|
881
|
+
CONTEXT_3: 0xa3,
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/** 读取一个 DER TLV;返回内容区 [start, end) 与标签 */
|
|
885
|
+
function readTlv(buffer, offset) {
|
|
886
|
+
if (!Number.isInteger(offset) || offset < 0 || offset + 2 > buffer.length) {
|
|
887
|
+
throw new Error(`DER 读取越界(offset=${offset})`)
|
|
888
|
+
}
|
|
889
|
+
const tag = buffer[offset]
|
|
890
|
+
const firstLength = buffer[offset + 1]
|
|
891
|
+
let length
|
|
892
|
+
let headerLength
|
|
893
|
+
if (firstLength < 0x80) {
|
|
894
|
+
length = firstLength
|
|
895
|
+
headerLength = 2
|
|
896
|
+
} else {
|
|
897
|
+
const byteCount = firstLength & 0x7f
|
|
898
|
+
if (byteCount === 0 || byteCount > 4 || offset + 2 + byteCount > buffer.length) {
|
|
899
|
+
throw new Error('DER 长度编码无效')
|
|
900
|
+
}
|
|
901
|
+
length = 0
|
|
902
|
+
for (let index = 0; index < byteCount; index += 1) {
|
|
903
|
+
length = length * 256 + buffer[offset + 2 + index]
|
|
904
|
+
}
|
|
905
|
+
headerLength = 2 + byteCount
|
|
906
|
+
}
|
|
907
|
+
const start = offset + headerLength
|
|
908
|
+
const end = start + length
|
|
909
|
+
if (end > buffer.length) {
|
|
910
|
+
throw new Error('DER 长度超出缓冲区')
|
|
911
|
+
}
|
|
912
|
+
return { tag, start, end }
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/** 解码 DER OID(content 部分) */
|
|
916
|
+
function decodeOid(buffer, tlv) {
|
|
917
|
+
if (tlv.tag !== TAG.OID) {
|
|
918
|
+
throw new Error('DER OID 标签无效')
|
|
919
|
+
}
|
|
920
|
+
const parts = []
|
|
921
|
+
let value = 0
|
|
922
|
+
let first = true
|
|
923
|
+
for (let index = tlv.start; index < tlv.end; index += 1) {
|
|
924
|
+
const byte = buffer[index]
|
|
925
|
+
value = value * 128 + (byte & 0x7f)
|
|
926
|
+
if ((byte & 0x80) === 0) {
|
|
927
|
+
if (first) {
|
|
928
|
+
if (value < 40) {
|
|
929
|
+
parts.push(0, value)
|
|
930
|
+
} else if (value < 80) {
|
|
931
|
+
parts.push(1, value - 40)
|
|
932
|
+
} else {
|
|
933
|
+
parts.push(2, value - 80)
|
|
934
|
+
}
|
|
935
|
+
first = false
|
|
936
|
+
} else {
|
|
937
|
+
parts.push(value)
|
|
938
|
+
}
|
|
939
|
+
value = 0
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
return parts.join('.')
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/** 解码 DER INTEGER(只用于版本号、pathLen 这类小整数) */
|
|
946
|
+
function decodeInteger(buffer, tlv) {
|
|
947
|
+
let value = 0
|
|
948
|
+
for (let index = tlv.start; index < tlv.end; index += 1) {
|
|
949
|
+
value = value * 256 + buffer[index]
|
|
950
|
+
}
|
|
951
|
+
return value
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/**
|
|
955
|
+
* 解析证书 DER,取出:
|
|
956
|
+
* - version:1/2/3(X.509 版本)
|
|
957
|
+
* - signatureAlgorithmOid:签名算法 OID(Node 18 没有 signatureAlgorithm 属性)
|
|
958
|
+
* - extensions:{ oid, critical, value }[],value 是 extnValue OCTET STRING 的内容
|
|
959
|
+
*/
|
|
960
|
+
function parseCertificateDer(der) {
|
|
961
|
+
const certificate = readTlv(der, 0)
|
|
962
|
+
if (certificate.tag !== TAG.SEQUENCE) {
|
|
963
|
+
throw new Error('证书不是 DER SEQUENCE')
|
|
964
|
+
}
|
|
965
|
+
const tbs = readTlv(der, certificate.start)
|
|
966
|
+
if (tbs.tag !== TAG.SEQUENCE) {
|
|
967
|
+
throw new Error('TBSCertificate 不是 DER SEQUENCE')
|
|
968
|
+
}
|
|
969
|
+
const signatureAlgorithm = readTlv(der, tbs.end)
|
|
970
|
+
const signatureAlgorithmOid = decodeOidInSequence(der, signatureAlgorithm)
|
|
971
|
+
|
|
972
|
+
let offset = tbs.start
|
|
973
|
+
let version = 1
|
|
974
|
+
let field = readTlv(der, offset)
|
|
975
|
+
if (field.tag === TAG.CONTEXT_0) {
|
|
976
|
+
const versionTlv = readTlv(der, field.start)
|
|
977
|
+
version = decodeInteger(der, versionTlv) + 1
|
|
978
|
+
offset = field.end
|
|
979
|
+
field = readTlv(der, offset)
|
|
980
|
+
}
|
|
981
|
+
// serialNumber
|
|
982
|
+
offset = field.end
|
|
983
|
+
// signature(TBSCertificate 里的 AlgorithmIdentifier)
|
|
984
|
+
field = readTlv(der, offset)
|
|
985
|
+
offset = field.end
|
|
986
|
+
// issuer / validity / subject / subjectPublicKeyInfo
|
|
987
|
+
for (let skip = 0; skip < 4; skip += 1) {
|
|
988
|
+
field = readTlv(der, offset)
|
|
989
|
+
offset = field.end
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
let extensions = []
|
|
993
|
+
while (offset < tbs.end) {
|
|
994
|
+
field = readTlv(der, offset)
|
|
995
|
+
if (field.tag === TAG.CONTEXT_3) {
|
|
996
|
+
extensions = parseExtensions(der, field)
|
|
997
|
+
}
|
|
998
|
+
offset = field.end
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
return { version, signatureAlgorithmOid, extensions }
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
/** AlgorithmIdentifier ::= SEQUENCE { algorithm OID, parameters ANY } */
|
|
1005
|
+
function decodeOidInSequence(buffer, tlv) {
|
|
1006
|
+
if (tlv.tag !== TAG.SEQUENCE) {
|
|
1007
|
+
throw new Error('AlgorithmIdentifier 不是 DER SEQUENCE')
|
|
1008
|
+
}
|
|
1009
|
+
const oidTlv = readTlv(buffer, tlv.start)
|
|
1010
|
+
return decodeOid(buffer, oidTlv)
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
/** Extensions ::= SEQUENCE OF Extension;[3] 是 EXPLICIT 标记,先剥掉外层 SEQUENCE */
|
|
1014
|
+
function parseExtensions(buffer, wrapper) {
|
|
1015
|
+
const outer = readTlv(buffer, wrapper.start)
|
|
1016
|
+
if (outer.tag !== TAG.SEQUENCE || outer.end !== wrapper.end) {
|
|
1017
|
+
throw new Error('Extensions 不是 DER SEQUENCE')
|
|
1018
|
+
}
|
|
1019
|
+
const extensions = []
|
|
1020
|
+
let offset = outer.start
|
|
1021
|
+
while (offset < outer.end) {
|
|
1022
|
+
const extension = readTlv(buffer, offset)
|
|
1023
|
+
let inner = extension.start
|
|
1024
|
+
const oidTlv = readTlv(buffer, inner)
|
|
1025
|
+
const oid = decodeOid(buffer, oidTlv)
|
|
1026
|
+
inner = oidTlv.end
|
|
1027
|
+
|
|
1028
|
+
let critical = false
|
|
1029
|
+
let next = readTlv(buffer, inner)
|
|
1030
|
+
if (next.tag === TAG.BOOLEAN) {
|
|
1031
|
+
critical = buffer[next.start] !== 0
|
|
1032
|
+
inner = next.end
|
|
1033
|
+
next = readTlv(buffer, inner)
|
|
1034
|
+
}
|
|
1035
|
+
if (next.tag !== TAG.OCTET_STRING) {
|
|
1036
|
+
throw new Error(`扩展 ${oid} 的 extnValue 不是 OCTET STRING`)
|
|
1037
|
+
}
|
|
1038
|
+
extensions.push({ oid, critical, value: buffer.subarray(next.start, next.end) })
|
|
1039
|
+
offset = extension.end
|
|
1040
|
+
}
|
|
1041
|
+
return extensions
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
/** 解析失败时返回 null:基础字段照常输出,扩展部分降级 */
|
|
1045
|
+
function parseCertificateDerSafe(der) {
|
|
1046
|
+
try {
|
|
1047
|
+
return parseCertificateDer(der)
|
|
1048
|
+
} catch {
|
|
1049
|
+
return null
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
// ------------------------------------------------------------- 签名算法
|
|
1054
|
+
|
|
1055
|
+
/**
|
|
1056
|
+
* 常见签名算法 OID → 名称。
|
|
1057
|
+
*
|
|
1058
|
+
* Node 26 起 X509Certificate 自带 signatureAlgorithm / signatureAlgorithmOid,
|
|
1059
|
+
* 但项目 engines 是 >=18;这里自带映射,保证老版本 Node 也能输出签名算法。
|
|
1060
|
+
*/
|
|
1061
|
+
const SIGNATURE_ALGORITHMS = {
|
|
1062
|
+
'1.2.840.113549.1.1.4': 'md5WithRSAEncryption',
|
|
1063
|
+
'1.2.840.113549.1.1.5': 'sha1WithRSAEncryption',
|
|
1064
|
+
'1.2.840.113549.1.1.10': 'RSASSA-PSS',
|
|
1065
|
+
'1.2.840.113549.1.1.11': 'sha256WithRSAEncryption',
|
|
1066
|
+
'1.2.840.113549.1.1.12': 'sha384WithRSAEncryption',
|
|
1067
|
+
'1.2.840.113549.1.1.13': 'sha512WithRSAEncryption',
|
|
1068
|
+
'1.2.840.113549.1.1.14': 'sha224WithRSAEncryption',
|
|
1069
|
+
'1.2.840.10040.4.3': 'dsa-with-SHA1',
|
|
1070
|
+
'1.2.840.10045.4.1': 'ecdsa-with-SHA1',
|
|
1071
|
+
'1.2.840.10045.4.3.1': 'ecdsa-with-SHA224',
|
|
1072
|
+
'1.2.840.10045.4.3.2': 'ecdsa-with-SHA256',
|
|
1073
|
+
'1.2.840.10045.4.3.3': 'ecdsa-with-SHA384',
|
|
1074
|
+
'1.2.840.10045.4.3.4': 'ecdsa-with-SHA512',
|
|
1075
|
+
'2.16.840.1.101.3.4.3.2': 'dsa-with-SHA256',
|
|
1076
|
+
'1.3.101.112': 'Ed25519',
|
|
1077
|
+
'1.3.101.113': 'Ed448',
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
/** 签名算法:优先用自带 DER 解析的 OID,其次用新版 Node 的属性 */
|
|
1081
|
+
function describeSignatureAlgorithm(certificate, parsed) {
|
|
1082
|
+
const oid = parsed?.signatureAlgorithmOid ?? certificate.signatureAlgorithmOid
|
|
1083
|
+
const name =
|
|
1084
|
+
(oid === undefined ? undefined : SIGNATURE_ALGORITHMS[oid]) ??
|
|
1085
|
+
certificate.signatureAlgorithm ??
|
|
1086
|
+
(oid === undefined ? undefined : oid)
|
|
1087
|
+
if (oid === undefined || name === oid) {
|
|
1088
|
+
return name ?? '未知'
|
|
1089
|
+
}
|
|
1090
|
+
return `${name} (${oid})`
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
// ------------------------------------------------------------- X.509 扩展
|
|
1094
|
+
|
|
1095
|
+
/** X.509 Key Usage 的位 → 名称(顺序即 RFC 5280 的 bit 0..8) */
|
|
1096
|
+
const KEY_USAGE_NAMES = [
|
|
1097
|
+
'数字签名',
|
|
1098
|
+
'内容承诺',
|
|
1099
|
+
'密钥加密',
|
|
1100
|
+
'数据加密',
|
|
1101
|
+
'密钥协商',
|
|
1102
|
+
'证书签名',
|
|
1103
|
+
'CRL 签名',
|
|
1104
|
+
'仅加密',
|
|
1105
|
+
'仅解密',
|
|
1106
|
+
]
|
|
1107
|
+
|
|
1108
|
+
/** Extended Key Usage 常见 OID → 名称 */
|
|
1109
|
+
const EXTENDED_KEY_USAGE_NAMES = {
|
|
1110
|
+
'1.3.6.1.5.5.7.3.1': 'TLS Web 服务器认证',
|
|
1111
|
+
'1.3.6.1.5.5.7.3.2': 'TLS Web 客户端认证',
|
|
1112
|
+
'1.3.6.1.5.5.7.3.3': '代码签名',
|
|
1113
|
+
'1.3.6.1.5.5.7.3.4': '电子邮件保护',
|
|
1114
|
+
'1.3.6.1.5.5.7.3.8': '时间戳',
|
|
1115
|
+
'1.3.6.1.5.5.7.3.9': 'OCSP 签名',
|
|
1116
|
+
'2.5.29.37.0': '任意扩展密钥用途',
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
/** Authority Information Access 的访问方式 OID → 名称 */
|
|
1120
|
+
const ACCESS_METHOD_NAMES = {
|
|
1121
|
+
'1.3.6.1.5.5.7.48.1': 'OCSP',
|
|
1122
|
+
'1.3.6.1.5.5.7.48.2': 'CA Issuers',
|
|
1123
|
+
'1.3.6.1.5.5.7.48.3': '时间戳',
|
|
1124
|
+
'1.3.6.1.5.5.7.48.5': 'CA Repository',
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
/** 按扩展 OID 输出可读信息;认不出的扩展输出 OID、critical 与十六进制内容 */
|
|
1128
|
+
function extensionLines(parsed) {
|
|
1129
|
+
if (parsed === null) {
|
|
1130
|
+
return []
|
|
1131
|
+
}
|
|
1132
|
+
const lines = []
|
|
1133
|
+
for (const extension of parsed.extensions) {
|
|
1134
|
+
const suffix = extension.critical ? '(critical)' : ''
|
|
1135
|
+
try {
|
|
1136
|
+
switch (extension.oid) {
|
|
1137
|
+
case '2.5.29.15':
|
|
1138
|
+
lines.push(line(`密钥用途(Key Usage)${suffix}`, decodeKeyUsage(extension.value)))
|
|
1139
|
+
break
|
|
1140
|
+
case '2.5.29.19':
|
|
1141
|
+
lines.push(line(`基本约束(Basic Constraints)${suffix}`, decodeBasicConstraints(extension.value)))
|
|
1142
|
+
break
|
|
1143
|
+
case '2.5.29.14':
|
|
1144
|
+
lines.push(line('主题密钥标识(Subject Key Identifier)', decodeKeyIdentifier(extension.value)))
|
|
1145
|
+
break
|
|
1146
|
+
case '2.5.29.35':
|
|
1147
|
+
lines.push(line('颁发者密钥标识(Authority Key Identifier)', decodeAuthorityKeyIdentifier(extension.value)))
|
|
1148
|
+
break
|
|
1149
|
+
case '2.5.29.17':
|
|
1150
|
+
lines.push(...decodeSubjectAlternativeName(extension.value))
|
|
1151
|
+
break
|
|
1152
|
+
case '2.5.29.18':
|
|
1153
|
+
lines.push(...decodeIssuerAlternativeName(extension.value))
|
|
1154
|
+
break
|
|
1155
|
+
case '2.5.29.37':
|
|
1156
|
+
lines.push(...decodeExtendedKeyUsage(extension.value))
|
|
1157
|
+
break
|
|
1158
|
+
case '2.5.29.31':
|
|
1159
|
+
lines.push(line('CRL 分发点(CRL Distribution Points)', decodeCrlDistributionPoints(extension.value)))
|
|
1160
|
+
break
|
|
1161
|
+
case '2.5.29.32':
|
|
1162
|
+
lines.push(...decodeCertificatePolicies(extension.value))
|
|
1163
|
+
break
|
|
1164
|
+
case '1.3.6.1.5.5.7.1.1':
|
|
1165
|
+
lines.push(...decodeAuthorityInfoAccess(extension.value))
|
|
1166
|
+
break
|
|
1167
|
+
case '1.3.6.1.5.5.7.1.11':
|
|
1168
|
+
lines.push(...decodeSubjectInfoAccess(extension.value))
|
|
1169
|
+
break
|
|
1170
|
+
default:
|
|
1171
|
+
lines.push(line(`扩展 ${extension.oid}${suffix}`, formatHexSummary(extension.value)))
|
|
1172
|
+
}
|
|
1173
|
+
} catch (error) {
|
|
1174
|
+
// 单个扩展解不出来不影响其它字段;把原因写出来,避免「静默少了信息」
|
|
1175
|
+
lines.push(line(`扩展 ${extension.oid}${suffix}`, `解析失败: ${error.message}`))
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
return lines
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
/** 密钥用途:extnValue 里是一个 BIT STRING,bit 0..8 对应 RFC 5280 的用途 */
|
|
1182
|
+
function decodeKeyUsage(value) {
|
|
1183
|
+
const bitString = readTlv(value, 0)
|
|
1184
|
+
if (bitString.tag !== TAG.BIT_STRING) {
|
|
1185
|
+
throw new Error('Key Usage 不是 BIT STRING')
|
|
1186
|
+
}
|
|
1187
|
+
const bytes = value.subarray(bitString.start + 1, bitString.end)
|
|
1188
|
+
const usages = []
|
|
1189
|
+
for (let index = 0; index < KEY_USAGE_NAMES.length; index += 1) {
|
|
1190
|
+
const byte = bytes[Math.floor(index / 8)]
|
|
1191
|
+
if (byte !== undefined && (byte & (0x80 >> (index % 8))) !== 0) {
|
|
1192
|
+
usages.push(KEY_USAGE_NAMES[index])
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
return usages.length > 0 ? usages.join('、') : '无'
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
/** 基本约束:SEQUENCE { cA BOOLEAN DEFAULT FALSE, pathLenConstraint INTEGER OPTIONAL } */
|
|
1199
|
+
function decodeBasicConstraints(value) {
|
|
1200
|
+
const sequence = readTlv(value, 0)
|
|
1201
|
+
if (sequence.tag !== TAG.SEQUENCE) {
|
|
1202
|
+
throw new Error('Basic Constraints 不是 SEQUENCE')
|
|
1203
|
+
}
|
|
1204
|
+
let ca = false
|
|
1205
|
+
let pathLength
|
|
1206
|
+
let offset = sequence.start
|
|
1207
|
+
while (offset < sequence.end) {
|
|
1208
|
+
const field = readTlv(value, offset)
|
|
1209
|
+
if (field.tag === TAG.BOOLEAN) {
|
|
1210
|
+
ca = value[field.start] !== 0
|
|
1211
|
+
} else if (field.tag === TAG.INTEGER) {
|
|
1212
|
+
pathLength = decodeInteger(value, field)
|
|
1213
|
+
}
|
|
1214
|
+
offset = field.end
|
|
1215
|
+
}
|
|
1216
|
+
return `CA:${ca ? 'TRUE' : 'FALSE'}${pathLength === undefined ? '' : `,pathLen:${pathLength}`}`
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
/** Subject Key Identifier:extnValue 里是 OCTET STRING */
|
|
1220
|
+
function decodeKeyIdentifier(value) {
|
|
1221
|
+
const octetString = readTlv(value, 0)
|
|
1222
|
+
if (octetString.tag !== TAG.OCTET_STRING) {
|
|
1223
|
+
throw new Error('Subject Key Identifier 不是 OCTET STRING')
|
|
1224
|
+
}
|
|
1225
|
+
return formatHex(value.subarray(octetString.start, octetString.end))
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
/** Authority Key Identifier:SEQUENCE 里 [0] keyIdentifier 是隐式标记的 OCTET STRING */
|
|
1229
|
+
function decodeAuthorityKeyIdentifier(value) {
|
|
1230
|
+
const sequence = readTlv(value, 0)
|
|
1231
|
+
if (sequence.tag !== TAG.SEQUENCE) {
|
|
1232
|
+
throw new Error('Authority Key Identifier 不是 SEQUENCE')
|
|
1233
|
+
}
|
|
1234
|
+
let offset = sequence.start
|
|
1235
|
+
while (offset < sequence.end) {
|
|
1236
|
+
const field = readTlv(value, offset)
|
|
1237
|
+
if (field.tag === 0x80) {
|
|
1238
|
+
return formatHex(value.subarray(field.start, field.end))
|
|
1239
|
+
}
|
|
1240
|
+
offset = field.end
|
|
1241
|
+
}
|
|
1242
|
+
return '(未包含 keyIdentifier)'
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
/** GeneralNames → 可读名称;SAN / IAN / AIA 共用 */
|
|
1246
|
+
function decodeGeneralNames(buffer, sequence) {
|
|
1247
|
+
if (sequence.tag !== TAG.SEQUENCE) {
|
|
1248
|
+
throw new Error('GeneralNames 不是 SEQUENCE')
|
|
1249
|
+
}
|
|
1250
|
+
const names = []
|
|
1251
|
+
let offset = sequence.start
|
|
1252
|
+
while (offset < sequence.end) {
|
|
1253
|
+
const name = readTlv(buffer, offset)
|
|
1254
|
+
names.push(formatGeneralName(buffer, name))
|
|
1255
|
+
offset = name.end
|
|
1256
|
+
}
|
|
1257
|
+
return names
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
/** GeneralName 的各分支(dNSName、URI、IP 等) */
|
|
1261
|
+
function formatGeneralName(buffer, tlv) {
|
|
1262
|
+
const content = buffer.subarray(tlv.start, tlv.end)
|
|
1263
|
+
switch (tlv.tag) {
|
|
1264
|
+
case 0x81:
|
|
1265
|
+
return `email:${content.toString('utf8')}`
|
|
1266
|
+
case 0x82:
|
|
1267
|
+
return `DNS:${content.toString('utf8')}`
|
|
1268
|
+
case 0x86:
|
|
1269
|
+
return `URI:${content.toString('utf8')}`
|
|
1270
|
+
case 0x87:
|
|
1271
|
+
return `IP:${formatIpAddress(content)}`
|
|
1272
|
+
case 0x88:
|
|
1273
|
+
// registeredID 用 [8] 隐式标记,内容与 OID 的 content 相同,补一个虚拟标签交给 decodeOid
|
|
1274
|
+
return `RID:${decodeOid(buffer, { tag: TAG.OID, start: tlv.start, end: tlv.end })}`
|
|
1275
|
+
case 0xa4:
|
|
1276
|
+
return `DirName:${formatHexSummary(content)}`
|
|
1277
|
+
default:
|
|
1278
|
+
return `tag 0x${tlv.tag.toString(16)}:${formatHexSummary(content)}`
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
/** IP 地址的二进制形式转文本(SAN 里的 iPAddress) */
|
|
1283
|
+
function formatIpAddress(buffer) {
|
|
1284
|
+
if (buffer.length === 4) {
|
|
1285
|
+
return [...buffer].join('.')
|
|
1286
|
+
}
|
|
1287
|
+
if (buffer.length === 16) {
|
|
1288
|
+
const groups = []
|
|
1289
|
+
for (let index = 0; index < 16; index += 2) {
|
|
1290
|
+
groups.push(buffer.readUInt16BE(index).toString(16))
|
|
1291
|
+
}
|
|
1292
|
+
return groups.join(':')
|
|
1293
|
+
}
|
|
1294
|
+
return formatHex(buffer)
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
/** Subject Alternative Name:每个名称一行,便于逐条查看 */
|
|
1298
|
+
function decodeSubjectAlternativeName(value) {
|
|
1299
|
+
const sequence = readTlv(value, 0)
|
|
1300
|
+
const names = decodeGeneralNames(value, sequence)
|
|
1301
|
+
return names.length === 0
|
|
1302
|
+
? [line('主体备用名称(Subject Alternative Name)', '(空)')]
|
|
1303
|
+
: names.map((name) => line('主体备用名称(Subject Alternative Name)', name))
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
/** Issuer Alternative Name:与 SAN 同构 */
|
|
1307
|
+
function decodeIssuerAlternativeName(value) {
|
|
1308
|
+
const sequence = readTlv(value, 0)
|
|
1309
|
+
const names = decodeGeneralNames(value, sequence)
|
|
1310
|
+
return names.length === 0
|
|
1311
|
+
? [line('颁发者备用名称(Issuer Alternative Name)', '(空)')]
|
|
1312
|
+
: names.map((name) => line('颁发者备用名称(Issuer Alternative Name)', name))
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
/** Extended Key Usage:SEQUENCE OF OID,每个用途一行 */
|
|
1316
|
+
function decodeExtendedKeyUsage(value) {
|
|
1317
|
+
const sequence = readTlv(value, 0)
|
|
1318
|
+
if (sequence.tag !== TAG.SEQUENCE) {
|
|
1319
|
+
throw new Error('Extended Key Usage 不是 SEQUENCE')
|
|
1320
|
+
}
|
|
1321
|
+
const usages = []
|
|
1322
|
+
let offset = sequence.start
|
|
1323
|
+
while (offset < sequence.end) {
|
|
1324
|
+
const field = readTlv(value, offset)
|
|
1325
|
+
const oid = decodeOid(value, field)
|
|
1326
|
+
const name = EXTENDED_KEY_USAGE_NAMES[oid]
|
|
1327
|
+
usages.push(name === undefined ? oid : `${name} (${oid})`)
|
|
1328
|
+
offset = field.end
|
|
1329
|
+
}
|
|
1330
|
+
return usages.length === 0
|
|
1331
|
+
? [line('扩展密钥用途(Extended Key Usage)', '(空)')]
|
|
1332
|
+
: usages.map((usage) => line('扩展密钥用途(Extended Key Usage)', usage))
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
/**
|
|
1336
|
+
* CRL 分发点:结构嵌套较深(DistributionPoint → DistributionPointName → GeneralNames),
|
|
1337
|
+
* 这里递归收集所有 GeneralName 分支,输出其中的 URI / DNS / IP。
|
|
1338
|
+
*/
|
|
1339
|
+
function decodeCrlDistributionPoints(value) {
|
|
1340
|
+
const names = []
|
|
1341
|
+
collectGeneralNames(value, readTlv(value, 0), names)
|
|
1342
|
+
return names.length > 0 ? names.join('、') : '(未包含可读地址)'
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
/** 递归遍历 DER,收集 GeneralName 分支(CRL 分发点用) */
|
|
1346
|
+
function collectGeneralNames(buffer, tlv, names) {
|
|
1347
|
+
if ([0x81, 0x82, 0x86, 0x87, 0x88].includes(tlv.tag)) {
|
|
1348
|
+
names.push(formatGeneralName(buffer, tlv))
|
|
1349
|
+
return
|
|
1350
|
+
}
|
|
1351
|
+
if ((tlv.tag & 0x20) === 0) {
|
|
1352
|
+
return
|
|
1353
|
+
}
|
|
1354
|
+
let offset = tlv.start
|
|
1355
|
+
while (offset < tlv.end) {
|
|
1356
|
+
const field = readTlv(buffer, offset)
|
|
1357
|
+
collectGeneralNames(buffer, field, names)
|
|
1358
|
+
offset = field.end
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
/** Certificate Policies:SEQUENCE OF PolicyInformation,每个策略 OID 一行 */
|
|
1363
|
+
function decodeCertificatePolicies(value) {
|
|
1364
|
+
const sequence = readTlv(value, 0)
|
|
1365
|
+
if (sequence.tag !== TAG.SEQUENCE) {
|
|
1366
|
+
throw new Error('Certificate Policies 不是 SEQUENCE')
|
|
1367
|
+
}
|
|
1368
|
+
const policies = []
|
|
1369
|
+
let offset = sequence.start
|
|
1370
|
+
while (offset < sequence.end) {
|
|
1371
|
+
const information = readTlv(value, offset)
|
|
1372
|
+
const oidTlv = readTlv(value, information.start)
|
|
1373
|
+
policies.push(decodeOid(value, oidTlv))
|
|
1374
|
+
offset = information.end
|
|
1375
|
+
}
|
|
1376
|
+
return policies.length === 0
|
|
1377
|
+
? [line('证书策略(Certificate Policies)', '(空)')]
|
|
1378
|
+
: policies.map((oid) => line('证书策略(Certificate Policies)', oid))
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
/** Authority / Subject Information Access 的公共解码:AccessDescription 序列 */
|
|
1382
|
+
function decodeInformationAccess(value, label) {
|
|
1383
|
+
const sequence = readTlv(value, 0)
|
|
1384
|
+
if (sequence.tag !== TAG.SEQUENCE) {
|
|
1385
|
+
throw new Error(`${label} 不是 SEQUENCE`)
|
|
1386
|
+
}
|
|
1387
|
+
const entries = []
|
|
1388
|
+
let offset = sequence.start
|
|
1389
|
+
while (offset < sequence.end) {
|
|
1390
|
+
const description = readTlv(value, offset)
|
|
1391
|
+
let inner = description.start
|
|
1392
|
+
const methodTlv = readTlv(value, inner)
|
|
1393
|
+
const method = decodeOid(value, methodTlv)
|
|
1394
|
+
inner = methodTlv.end
|
|
1395
|
+
const location = readTlv(value, inner)
|
|
1396
|
+
entries.push({
|
|
1397
|
+
method: ACCESS_METHOD_NAMES[method] ?? `${label} ${method}`,
|
|
1398
|
+
location: formatGeneralName(value, location),
|
|
1399
|
+
})
|
|
1400
|
+
offset = description.end
|
|
1401
|
+
}
|
|
1402
|
+
return entries
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
/** Authority Information Access:OCSP / CA Issuers 地址 */
|
|
1406
|
+
function decodeAuthorityInfoAccess(value) {
|
|
1407
|
+
return decodeInformationAccess(value, 'AIA').map((entry) =>
|
|
1408
|
+
line(`${entry.method}`, entry.location),
|
|
1409
|
+
)
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
/** Subject Information Access:CA Repository / 时间戳地址 */
|
|
1413
|
+
function decodeSubjectInfoAccess(value) {
|
|
1414
|
+
return decodeInformationAccess(value, 'SIA').map((entry) =>
|
|
1415
|
+
line(`${entry.method}`, entry.location),
|
|
1416
|
+
)
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
/** 未知扩展 / 无法解析内容时的十六进制摘要;过长的值截断,避免一行刷屏 */
|
|
1420
|
+
function formatHexSummary(buffer) {
|
|
1421
|
+
if (buffer.length === 0) {
|
|
1422
|
+
return '(空)'
|
|
1423
|
+
}
|
|
1424
|
+
const shown = 32
|
|
1425
|
+
const hex = formatHex(buffer.subarray(0, shown))
|
|
1426
|
+
return buffer.length > shown ? `${hex} …(共 ${buffer.length} 字节)` : hex
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
// ------------------------------------------------------------------ 命令入口
|
|
1430
|
+
|
|
1431
|
+
/** cert 命令:解析选项、读取证书 / PKCS#12,并输出信息 */
|
|
1432
|
+
function runCert(argv, env) {
|
|
1433
|
+
if (argv.some((token) => isHelpToken(token))) {
|
|
1434
|
+
stdout(renderCertHelp())
|
|
1435
|
+
return EXIT_OK
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
const options = parseOptions(argv, CERT_OPTIONS, renderCertHelp, CERT_FLAGS)
|
|
1439
|
+
const verbose = resolveVerbose(options, env, renderCertHelp)
|
|
1440
|
+
const path = options.get(OPTION_FILE) ?? readEnv(env, ENV_FILE)
|
|
1441
|
+
if (path === undefined) {
|
|
1442
|
+
throw new UsageError(
|
|
1443
|
+
`缺少必填参数: ${OPTION_FILE}(证书 / PKCS#12 文件路径),可用环境变量 ${ENV_FILE}`,
|
|
1444
|
+
renderCertHelp(),
|
|
1445
|
+
)
|
|
1446
|
+
}
|
|
1447
|
+
const password = options.get(OPTION_PASSWORD) ?? readEnv(env, ENV_PASSWORD)
|
|
1448
|
+
|
|
1449
|
+
const input = readInput(path, password, renderCertHelp)
|
|
1450
|
+
stdout(
|
|
1451
|
+
input.kind === 'pkcs12'
|
|
1452
|
+
? renderPkcs12(path, input, verbose)
|
|
1453
|
+
: renderCertificates(path, input, verbose),
|
|
1454
|
+
)
|
|
1455
|
+
return EXIT_OK
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
/**
|
|
1459
|
+
* 证书命令描述,由 publish-apk.js 注册到根命令表。
|
|
1460
|
+
*
|
|
1461
|
+
* 与 huawei / google 不同,cert 不属于任何厂商:它只读本地证书 / 密钥库文件,
|
|
1462
|
+
* 因此单独放在 TOOLS 里,而不是 VENDORS。
|
|
1463
|
+
*/
|
|
1464
|
+
export const certCommand = {
|
|
1465
|
+
name: COMMAND_NAME,
|
|
1466
|
+
summary: '查看证书 / PKCS#12 密钥库信息',
|
|
1467
|
+
run: runCert,
|
|
1468
|
+
}
|