Changes to be committed:
Some checks failed
Build and Deploy / build (push) Failing after 59s

modified:   src/App.tsx
	new file:   src/__tests__/utils/extract-cert.test.ts
	new file:   src/utils/extract-cert.js
	modified:   src/version.js
This commit is contained in:
cheney 2026-08-07 11:33:57 +08:00
parent 2a2e9e0d6b
commit 2adcf8f077
4 changed files with 420 additions and 2 deletions

View File

@ -7,6 +7,7 @@ import { sm2GenerateKeyPair, sm2GetPublicKeyFromPrivateKey, sm2Encrypt, sm2Decry
import { parseSm2Cert, parseCertFromFile, generateRootCert, generateAppCert, generateCrlFile, loadRootPrivateKeyFromCache } from './utils/algorithm/cert'
import { hexToUtf8, utf8ToHex, hexToBase64, base64ToHex } from './utils/convert'
import { getInfoFromPKCS7, verifyPKCS7 } from './utils/crypto.js'
import { extractCertificate, extractCertificateFromText } from './utils/extract-cert.js'
function App() {
// 从localStorage读取当前分类默认sm3
@ -189,6 +190,9 @@ function App() {
const [pkcs7Info, setPkcs7Info] = useState<any>(null)
const [originalData, setOriginalData] = useState<string>('')
const [verifyResult, setVerifyResult] = useState<string>('')
const [extractInput, setExtractInput] = useState<string>('')
const [extractedCert, setExtractedCert] = useState<any>(null)
const [extractError, setExtractError] = useState<string>('')
@ -410,6 +414,36 @@ function App() {
}
}
const handleExtractCertificate = () => {
try {
const result = extractCertificate(extractInput)
setExtractedCert(result)
setExtractError('')
} catch (error) {
setExtractError((error as Error).message)
setExtractedCert(null)
}
}
const handleDownloadExtractedCert = (type: 'der' | 'pem') => {
if (!extractedCert) {
setExtractError('请先提取证书')
return
}
const content = type === 'der' ? extractedCert.der : extractedCert.pem
const blob = new Blob([content], { type: type === 'der' ? 'application/octet-stream' : 'application/x-pem-file' })
const downloadUrl = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = downloadUrl
link.download = type === 'der' ? 'extracted-cert.der' : 'extracted-cert.pem'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(downloadUrl)
setExtractError('')
}
// 处理 PKCS7 签名信息提取
const handleGetInfoFromPKCS7 = () => {
try {
@ -1899,9 +1933,89 @@ function App() {
</button>
</div>
{/* 提取证书 */}
<div style={{ backgroundColor: '#2d2d2d', padding: '15px', borderRadius: '8px', marginBottom: '20px' }}>
<h3 style={{ color: '#f59e0b', marginBottom: '15px' }}>4. </h3>
<div className="form-group" style={{ marginBottom: '15px' }}>
<label style={{ display: 'block', marginBottom: '5px', color: '#e0e0e0' }}>DER / HEX / PEM / CMS</label>
<textarea
value={extractInput}
onChange={(e) => setExtractInput(e.target.value)}
onContextMenu={(e) => openMenu(e, extractInput, setExtractInput, false)}
onTouchStart={(e) => {
const touchTimer = setTimeout(() => {
openMenu(e, extractInput, setExtractInput, false)
}, 500)
return () => clearTimeout(touchTimer)
}}
placeholder="可直接粘贴证书 DER/HEX/PEM 或 PKCS#7/CMS 数据"
style={{
width: '100%',
padding: '10px',
border: '1px solid #333',
borderRadius: '4px',
backgroundColor: '#3d3d3d',
color: '#e0e0e0',
fontSize: '14px',
resize: 'vertical',
minHeight: '140px'
}}
/>
</div>
<button
onClick={handleExtractCertificate}
style={{
padding: '10px 20px',
border: 'none',
borderRadius: '4px',
background: '#f59e0b',
color: '#121212',
fontSize: '16px',
fontWeight: 'bold',
cursor: 'pointer',
transition: 'background 0.3s ease'
}}
>
</button>
{extractError && (
<div style={{ marginTop: '10px', color: '#fca5a5' }}>{extractError}</div>
)}
{extractedCert && (
<div style={{ marginTop: '15px', backgroundColor: '#3d3d3d', padding: '15px', borderRadius: '8px' }}>
<h4 style={{ color: '#f59e0b', marginBottom: '10px' }}></h4>
<div style={{ marginBottom: '10px', color: '#e0e0e0' }}>
<span style={{ fontWeight: 'bold' }}>:</span> {extractedCert.via}
</div>
<div style={{ marginBottom: '10px', color: '#e0e0e0' }}>
<span style={{ fontWeight: 'bold' }}>:</span> {extractedCert.length} bytes
</div>
<textarea
value={extractedCert.pem}
readOnly
style={{
width: '100%',
padding: '10px',
border: '1px solid #333',
borderRadius: '4px',
backgroundColor: '#2d2d2d',
color: '#e0e0e0',
fontSize: '14px',
resize: 'vertical',
minHeight: '160px'
}}
/>
<div style={{ display: 'flex', gap: '10px', marginTop: '10px', flexWrap: 'wrap' }}>
<button onClick={() => handleDownloadExtractedCert('der')} style={{ padding: '10px 16px', border: 'none', borderRadius: '4px', background: '#f59e0b', color: '#121212', fontWeight: 'bold', cursor: 'pointer' }}> DER</button>
<button onClick={() => handleDownloadExtractedCert('pem')} style={{ padding: '10px 16px', border: 'none', borderRadius: '4px', background: '#f59e0b', color: '#121212', fontWeight: 'bold', cursor: 'pointer' }}> PEM</button>
</div>
</div>
)}
</div>
{/* 证书解析 */}
<div style={{ backgroundColor: '#2d2d2d', padding: '15px', borderRadius: '8px' }}>
<h3 style={{ color: '#f59e0b', marginBottom: '15px' }}>4. </h3>
<h3 style={{ color: '#f59e0b', marginBottom: '15px' }}>5. </h3>
<div className="form-group" style={{ marginBottom: '20px' }}>
<label style={{ display: 'block', marginBottom: '10px', color: '#e0e0e0' }}></label>
<div style={{ position: 'relative', display: 'inline-block', width: '100%' }}>

View File

@ -0,0 +1,23 @@
import { extractCertificate } from '../../utils/extract-cert.js'
describe('extractCertificate', () => {
it('extracts a DER-like certificate sequence from raw bytes', () => {
const certBody = new Uint8Array(120)
certBody[0] = 0x02
certBody[1] = 0x01
certBody[2] = 0x01
const der = new Uint8Array(122)
der[0] = 0x30
der[1] = 0x78
der[2] = 0x02
der[3] = 0x01
der[4] = 0x01
der.set(certBody, 5)
const result = extractCertificate(der)
expect(result.via).toBe('direct certificate')
expect(result.pem).toContain('BEGIN CERTIFICATE')
expect(result.der.length).toBe(der.length)
})
})

281
src/utils/extract-cert.js Normal file
View File

@ -0,0 +1,281 @@
#!/usr/bin/env node
/**
* PKCS#7/CMS ContentInfo (DER) 中抽取内含的 X.509 证书
*
* 可在浏览器和 Node 环境中使用
* 支持输入:
* (a) CMS ContentInfo { OID, [0] EXPLICIT SEQUENCE(SignedData) }
* (b) 直接是一张 X.509 证书 DER
* (c) 其他 ASN.1 结构中嵌套证书 (fallback 全文扫描)
*/
'use strict';
function toByteArray(input) {
if (input == null) return null
if (input instanceof Uint8Array) return input
if (ArrayBuffer.isView(input)) return new Uint8Array(input.buffer, input.byteOffset, input.byteLength)
if (input instanceof ArrayBuffer) return new Uint8Array(input)
if (typeof input === 'string') {
const trimmed = input.trim()
if (!trimmed) return new Uint8Array(0)
if (/^-----BEGIN/.test(trimmed)) {
const base64Body = trimmed
.replace(/-----BEGIN CERTIFICATE-----/i, '')
.replace(/-----END CERTIFICATE-----/i, '')
.replace(/\s+/g, '')
return base64ToBytes(base64Body)
}
if (/^[0-9a-fA-F\s]+$/.test(trimmed)) {
return hexToBytes(trimmed)
}
return new TextEncoder().encode(trimmed)
}
return null
}
function base64ToBytes(value) {
if (typeof window !== 'undefined' && typeof window.atob === 'function') {
const binary = window.atob(value)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
return bytes
}
if (typeof Buffer !== 'undefined') {
return new Uint8Array(Buffer.from(value, 'base64'))
}
return new Uint8Array([])
}
function hexToBytes(value) {
const cleaned = value.replace(/\s+/g, '')
const bytes = new Uint8Array(cleaned.length / 2)
for (let i = 0; i < cleaned.length; i += 2) {
const chunk = cleaned.slice(i, i + 2)
if (!chunk) continue
bytes[i / 2] = Number.parseInt(chunk, 16)
}
return bytes
}
function bytesToHex(bytes) {
let out = ''
for (const byte of bytes) out += byte.toString(16).padStart(2, '0')
return out
}
// ---------- DER / ASN.1 最小解析 ----------
function readTLV(buf, offset) {
if (offset >= buf.length) return null
const tag = buf[offset++]
if ((tag & 0x1f) === 0x1f) return null
let len = buf[offset++]
let headerLen = 2
if (len & 0x80) {
const n = len & 0x7f
if (n === 0 || offset + n > buf.length) return null
len = 0
for (let i = 0; i < n; i++) len = (len << 8) | buf[offset++]
headerLen = 2 + n
}
if (offset + len > buf.length) return null
return { tag, headerLen, contentLen: len, totalLen: headerLen + len, contentStart: offset, contentEnd: offset + len }
}
function readChildren(buf, start, end) {
const out = []
let pos = start
while (pos < end) {
const tlv = readTLV(buf, pos)
if (!tlv || tlv.totalLen <= 0) break
out.push(tlv)
pos += tlv.totalLen
}
return out
}
// ---------- 证书抽取 ----------
function looksLikeCertificate(buf, tlv) {
if (tlv.tag !== 0x30) return false
if (tlv.contentLen < 100 || tlv.contentLen > 8192) return false
const first = buf[tlv.contentStart]
if (first === 0x02) return true
if (first === 0xa0) {
const wrap = readTLV(buf, tlv.contentStart)
if (!wrap || wrap.tag !== 0xa0) return false
const inner = readTLV(buf, wrap.contentStart)
return !!(inner && inner.tag === 0x02)
}
return false
}
function findCertificateSEQUENCE(buf) {
for (let i = 0; i < buf.length - 4; i++) {
if (buf[i] !== 0x30) continue
const tlv = readTLV(buf, i)
if (tlv && looksLikeCertificate(buf, tlv)) {
return { start: i, end: i + tlv.totalLen }
}
}
return null
}
function pickCertInCertificatesField(buf, start, end) {
if (end - start > 4 && buf[start] === 0x30) {
const wrap = readTLV(buf, start)
if (wrap && wrap.tag === 0x30 && wrap.totalLen === (end - start)) {
const items = readChildren(buf, wrap.contentStart, wrap.contentEnd)
if (items.length > 0 && looksLikeCertificate(buf, items[0])) {
return { start: items[0].contentStart - items[0].headerLen, end: items[0].contentEnd }
}
}
}
for (const c of readChildren(buf, start, end)) {
if (looksLikeCertificate(buf, c)) {
return { start: c.contentStart - c.headerLen, end: c.contentEnd }
}
}
return null
}
function findCertViaCMS(buf) {
const top = readTLV(buf, 0)
if (!top || top.tag !== 0x30 || top.contentLen < 8) return null
const topChildren = readChildren(buf, top.contentStart, top.contentEnd)
if (topChildren.length < 2) return null
const wrap0 = topChildren[1]
if (wrap0.tag !== 0xa0) return null
const sdTLV = readTLV(buf, wrap0.contentStart)
if (!sdTLV || sdTLV.tag !== 0x30) return null
const sdChildren = readChildren(buf, sdTLV.contentStart, sdTLV.contentEnd)
if (sdChildren.length < 4) return null
const certsField = sdChildren[3]
if (certsField.tag !== 0xa0) return null
return pickCertInCertificatesField(buf, certsField.contentStart, certsField.contentEnd)
}
function locateCertificate(buf) {
const tlv = readTLV(buf, 0)
if (tlv && tlv.tag === 0x30 && looksLikeCertificate(buf, tlv)) {
return { range: { start: 0, end: tlv.totalLen }, via: 'direct certificate' }
}
const cms = findCertViaCMS(buf)
if (cms) return { range: cms, via: 'CMS ContentInfo' }
const fb = findCertificateSEQUENCE(buf)
if (fb) return { range: fb, via: 'fallback scan' }
return null
}
// ---------- 输出辅助 ----------
function toPEM(der) {
const b64 = bytesToBase64(der)
const lines = b64.match(/.{1,64}/g)?.join('\n') || b64
return '-----BEGIN CERTIFICATE-----\n' + lines + '\n-----END CERTIFICATE-----\n'
}
function bytesToBase64(bytes) {
const binary = Array.from(bytes, (byte) => String.fromCharCode(byte)).join('')
if (typeof window !== 'undefined' && typeof window.btoa === 'function') {
return window.btoa(binary)
}
if (typeof Buffer !== 'undefined') {
return Buffer.from(bytes).toString('base64')
}
return ''
}
export function extractCertificate(input) {
const buf = toByteArray(input)
if (!buf) {
throw new Error('unsupported input type')
}
const located = locateCertificate(buf)
if (!located) {
throw new Error('no X.509 certificate SEQUENCE found')
}
const { range, via } = located
const der = buf.subarray(range.start, range.end)
return {
der,
pem: toPEM(der),
hex: bytesToHex(der),
via,
start: range.start,
end: range.end,
length: der.length,
}
}
export function extractCertificateFromText(input) {
return extractCertificate(input)
}
// ---------- CLI ----------
function parseArgs(argv) {
const args = { input: null, out: null, pem: false, text: false }
for (let i = 2; i < argv.length; i++) {
const a = argv[i]
if (a === '--out') args.out = argv[++i]
else if (a === '--pem') args.pem = true
else if (a === '--text') args.text = true
else if (!args.input) args.input = a
}
if (!args.input) {
console.error('usage: node extract-cert.js <input.cer> [--out out.cer] [--pem] [--text]')
process.exit(2)
}
return args
}
async function main() {
if (typeof process === 'undefined' || !process.argv || process.argv[1] === undefined) return
const { default: fs } = await import('node:fs')
const { default: path } = await import('node:path')
const args = parseArgs(process.argv)
const buf = fs.readFileSync(args.input)
const located = locateCertificate(buf)
if (!located) {
console.error('no X.509 certificate SEQUENCE found')
process.exit(1)
}
const { range, via } = located
const der = buf.subarray(range.start, range.end)
console.error('[+] located via ' + via + ': offset=' + range.start + '..' + range.end + ' (' + der.length + ' bytes)')
const outDer = args.out || path.join(path.dirname(args.input), path.basename(args.input, path.extname(args.input)) + '.cert.der')
fs.writeFileSync(outDer, der)
console.error('[+] DER -> ' + outDer)
if (args.pem) {
const outPem = outDer.replace(/\.der$/i, '.pem')
fs.writeFileSync(outPem, toPEM(der))
console.error('[+] PEM -> ' + outPem)
}
if (args.text) {
console.log('=== X.509 certificate summary ===')
console.log('via:', via)
console.log('offset:', range.start, '..', range.end)
console.log('length:', der.length)
}
}
if (typeof process !== 'undefined' && process.argv?.[1] && process.argv[1].includes('extract-cert')) {
main()
}

View File

@ -1 +1 @@
export const VERSION = "V1.0-20260428031400"
export const VERSION = "V1.0-20260807030735";