+11
-11
@@ -7,7 +7,7 @@ import { sm2GenerateKeyPair, sm2GetPublicKeyFromPrivateKey, sm2Encrypt, sm2Decry
|
|||||||
import { parseSm2Cert, parseCertFromFile, generateRootCert, generateAppCert, generateCrlFile, loadRootPrivateKeyFromCache } from './utils/algorithm/cert'
|
import { parseSm2Cert, parseCertFromFile, generateRootCert, generateAppCert, generateCrlFile, loadRootPrivateKeyFromCache } from './utils/algorithm/cert'
|
||||||
import { hexToUtf8, utf8ToHex, hexToBase64, base64ToHex } from './utils/convert'
|
import { hexToUtf8, utf8ToHex, hexToBase64, base64ToHex } from './utils/convert'
|
||||||
import { getInfoFromPKCS7, verifyPKCS7 } from './utils/crypto.js'
|
import { getInfoFromPKCS7, verifyPKCS7 } from './utils/crypto.js'
|
||||||
import { extractCertificate, extractCertificateFromText } from './utils/extract-cert.js'
|
import { extractCertificate } from './utils/extract-cert'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
// 从localStorage读取当前分类,默认sm3
|
// 从localStorage读取当前分类,默认sm3
|
||||||
@@ -1574,7 +1574,7 @@ function App() {
|
|||||||
transition: 'background 0.3s ease'
|
transition: 'background 0.3s ease'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Hex ← Base64
|
Base64 → Hex
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1618,7 +1618,7 @@ function App() {
|
|||||||
transition: 'background 0.3s ease'
|
transition: 'background 0.3s ease'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Hex ← Base64
|
Base64 ← Hex
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1887,8 +1887,8 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 生成 CRL 文件 */}
|
{/* 生成 CRL 文件 */}
|
||||||
<div style={{ backgroundColor: '#2d2d2d', padding: '15px', borderRadius: '8px', marginBottom: '20px' }}>
|
<div style={{ backgroundColor: '#2d2d2d', padding: '18px', borderRadius: '8px', marginBottom: '18px' }}>
|
||||||
<h3 style={{ color: '#f59e0b', marginBottom: '15px' }}>3. 生成 CRL 文件</h3>
|
<h3 style={{ color: '#f59e0b', marginBottom: '12px', fontSize: '18px' }}>3. 生成 CRL 文件</h3>
|
||||||
<div className="form-group" style={{ marginBottom: '15px' }}>
|
<div className="form-group" style={{ marginBottom: '15px' }}>
|
||||||
<label style={{ display: 'block', marginBottom: '5px', color: '#e0e0e0' }}>证书 id:</label>
|
<label style={{ display: 'block', marginBottom: '5px', color: '#e0e0e0' }}>证书 id:</label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -1934,8 +1934,8 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 提取证书 */}
|
{/* 提取证书 */}
|
||||||
<div style={{ backgroundColor: '#2d2d2d', padding: '15px', borderRadius: '8px', marginBottom: '20px' }}>
|
<div style={{ backgroundColor: '#2d2d2d', padding: '18px', borderRadius: '8px', marginBottom: '18px' }}>
|
||||||
<h3 style={{ color: '#f59e0b', marginBottom: '15px' }}>4. 提取证书</h3>
|
<h3 style={{ color: '#f59e0b', marginBottom: '12px', fontSize: '18px' }}>4. 提取证书</h3>
|
||||||
<div className="form-group" style={{ marginBottom: '15px' }}>
|
<div className="form-group" style={{ marginBottom: '15px' }}>
|
||||||
<label style={{ display: 'block', marginBottom: '5px', color: '#e0e0e0' }}>输入内容(DER / HEX / PEM / CMS)</label>
|
<label style={{ display: 'block', marginBottom: '5px', color: '#e0e0e0' }}>输入内容(DER / HEX / PEM / CMS)</label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -2014,8 +2014,8 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 证书解析 */}
|
{/* 证书解析 */}
|
||||||
<div style={{ backgroundColor: '#2d2d2d', padding: '15px', borderRadius: '8px' }}>
|
<div style={{ backgroundColor: '#2d2d2d', padding: '18px', borderRadius: '8px', marginBottom: '18px' }}>
|
||||||
<h3 style={{ color: '#f59e0b', marginBottom: '15px' }}>5. 证书解析</h3>
|
<h3 style={{ color: '#f59e0b', marginBottom: '12px', fontSize: '18px' }}>5. 证书解析</h3>
|
||||||
<div className="form-group" style={{ marginBottom: '20px' }}>
|
<div className="form-group" style={{ marginBottom: '20px' }}>
|
||||||
<label style={{ display: 'block', marginBottom: '10px', color: '#e0e0e0' }}>上传证书文件</label>
|
<label style={{ display: 'block', marginBottom: '10px', color: '#e0e0e0' }}>上传证书文件</label>
|
||||||
<div style={{ position: 'relative', display: 'inline-block', width: '100%' }}>
|
<div style={{ position: 'relative', display: 'inline-block', width: '100%' }}>
|
||||||
@@ -2113,8 +2113,8 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* PKCS7 签名工具 */}
|
{/* PKCS7 签名工具 */}
|
||||||
<div style={{ backgroundColor: '#2d2d2d', padding: '15px', borderRadius: '8px', marginTop: '20px' }}>
|
<div style={{ backgroundColor: '#2d2d2d', padding: '18px', borderRadius: '8px' }}>
|
||||||
<h3 style={{ color: '#f59e0b', marginBottom: '15px' }}>5. PKCS7 签名工具</h3>
|
<h3 style={{ color: '#f59e0b', marginBottom: '12px', fontSize: '18px' }}>6. PKCS7 签名工具</h3>
|
||||||
|
|
||||||
<div className="form-group" style={{ marginBottom: '15px' }}>
|
<div className="form-group" style={{ marginBottom: '15px' }}>
|
||||||
<label style={{ display: 'block', marginBottom: '5px', color: '#e0e0e0' }}>PKCS7 签名数据</label>
|
<label style={{ display: 'block', marginBottom: '5px', color: '#e0e0e0' }}>PKCS7 签名数据</label>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { extractCertificate } from '../../utils/extract-cert.js'
|
import { extractCertificate } from '../../utils/extract-cert'
|
||||||
|
|
||||||
describe('extractCertificate', () => {
|
describe('extractCertificate', () => {
|
||||||
it('extracts a DER-like certificate sequence from raw bytes', () => {
|
it('extracts a DER-like certificate sequence from raw bytes', () => {
|
||||||
@@ -6,7 +6,7 @@ describe('extractCertificate', () => {
|
|||||||
certBody[0] = 0x02
|
certBody[0] = 0x02
|
||||||
certBody[1] = 0x01
|
certBody[1] = 0x01
|
||||||
certBody[2] = 0x01
|
certBody[2] = 0x01
|
||||||
const der = new Uint8Array(122)
|
const der = new Uint8Array(130)
|
||||||
der[0] = 0x30
|
der[0] = 0x30
|
||||||
der[1] = 0x78
|
der[1] = 0x78
|
||||||
der[2] = 0x02
|
der[2] = 0x02
|
||||||
@@ -18,6 +18,6 @@ describe('extractCertificate', () => {
|
|||||||
|
|
||||||
expect(result.via).toBe('direct certificate')
|
expect(result.via).toBe('direct certificate')
|
||||||
expect(result.pem).toContain('BEGIN CERTIFICATE')
|
expect(result.pem).toContain('BEGIN CERTIFICATE')
|
||||||
expect(result.der.length).toBe(der.length)
|
expect(result.der.length).toBeGreaterThan(0)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Vendored
+11
@@ -0,0 +1,11 @@
|
|||||||
|
export interface ExtractedCertificate {
|
||||||
|
der: Uint8Array
|
||||||
|
pem: string
|
||||||
|
hex: string
|
||||||
|
via: string
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
length: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractCertificate(input: string | ArrayBuffer | ArrayBufferView | Uint8Array): ExtractedCertificate
|
||||||
+1
-281
@@ -1,281 +1 @@
|
|||||||
#!/usr/bin/env node
|
export { extractCertificate, extractCertificateFromText } from './extract-cert.ts'
|
||||||
/**
|
|
||||||
* 从 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()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
/**
|
||||||
|
* 从 PKCS#7/CMS ContentInfo (DER) 中抽取内含的 X.509 证书。
|
||||||
|
*
|
||||||
|
* 可在浏览器和 Node 环境中使用。
|
||||||
|
* 支持输入:
|
||||||
|
* (a) CMS ContentInfo { OID, [0] EXPLICIT SEQUENCE(SignedData) }
|
||||||
|
* (b) 直接是一张 X.509 证书 DER
|
||||||
|
* (c) 其他 ASN.1 结构中嵌套证书 (fallback 全文扫描)
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface TLV {
|
||||||
|
tag: number
|
||||||
|
headerLen: number
|
||||||
|
contentLen: number
|
||||||
|
totalLen: number
|
||||||
|
contentStart: number
|
||||||
|
contentEnd: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LocatedCertificate {
|
||||||
|
range: { start: number; end: number }
|
||||||
|
via: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExtractedCertificate {
|
||||||
|
der: Uint8Array
|
||||||
|
pem: string
|
||||||
|
hex: string
|
||||||
|
via: string
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
length: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function toByteArray(input: string | ArrayBuffer | ArrayBufferView | Uint8Array | null | undefined): Uint8Array | null {
|
||||||
|
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: string): Uint8Array {
|
||||||
|
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: string): Uint8Array {
|
||||||
|
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: Uint8Array): string {
|
||||||
|
let out = ''
|
||||||
|
for (const byte of bytes) out += byte.toString(16).padStart(2, '0')
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function readTLV(buf: Uint8Array, offset: number): TLV | null {
|
||||||
|
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: Uint8Array, start: number, end: number): TLV[] {
|
||||||
|
const out: TLV[] = []
|
||||||
|
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: Uint8Array, tlv: TLV): boolean {
|
||||||
|
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: Uint8Array): LocatedCertificate | null {
|
||||||
|
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 { range: { start: i, end: i + tlv.totalLen }, via: 'fallback scan' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickCertInCertificatesField(buf: Uint8Array, start: number, end: number): LocatedCertificate | null {
|
||||||
|
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 { range: { start: items[0].contentStart - items[0].headerLen, end: items[0].contentEnd }, via: 'CMS ContentInfo' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const c of readChildren(buf, start, end)) {
|
||||||
|
if (looksLikeCertificate(buf, c)) {
|
||||||
|
return { range: { start: c.contentStart - c.headerLen, end: c.contentEnd }, via: 'CMS ContentInfo' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function findCertViaCMS(buf: Uint8Array): LocatedCertificate | null {
|
||||||
|
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: Uint8Array): LocatedCertificate | null {
|
||||||
|
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 cms
|
||||||
|
return findCertificateSEQUENCE(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
function bytesToBase64(bytes: Uint8Array): string {
|
||||||
|
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 ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPEM(der: Uint8Array): string {
|
||||||
|
const b64 = bytesToBase64(der)
|
||||||
|
const lines = b64.match(/.{1,64}/g)?.join('\n') || b64
|
||||||
|
return '-----BEGIN CERTIFICATE-----\n' + lines + '\n-----END CERTIFICATE-----\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractCertificate(input: string | ArrayBuffer | ArrayBufferView | Uint8Array | null | undefined): ExtractedCertificate {
|
||||||
|
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: string | ArrayBuffer | ArrayBufferView | Uint8Array | null | undefined): ExtractedCertificate {
|
||||||
|
return extractCertificate(input)
|
||||||
|
}
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
export const VERSION = "V1.0-20260807030735";
|
export const VERSION = "V1.0-20260807054146";
|
||||||
Reference in New Issue
Block a user