+
6. PKCS7 签名工具
diff --git a/src/__tests__/utils/extract-cert.test.ts b/src/__tests__/utils/extract-cert.test.ts
index 936915f..0a5b0e9 100644
--- a/src/__tests__/utils/extract-cert.test.ts
+++ b/src/__tests__/utils/extract-cert.test.ts
@@ -1,4 +1,4 @@
-import { extractCertificate } from '../../utils/extract-cert.js'
+import { extractCertificate } from '../../utils/extract-cert'
describe('extractCertificate', () => {
it('extracts a DER-like certificate sequence from raw bytes', () => {
@@ -6,7 +6,7 @@ describe('extractCertificate', () => {
certBody[0] = 0x02
certBody[1] = 0x01
certBody[2] = 0x01
- const der = new Uint8Array(122)
+ const der = new Uint8Array(130)
der[0] = 0x30
der[1] = 0x78
der[2] = 0x02
@@ -18,6 +18,6 @@ describe('extractCertificate', () => {
expect(result.via).toBe('direct certificate')
expect(result.pem).toContain('BEGIN CERTIFICATE')
- expect(result.der.length).toBe(der.length)
+ expect(result.der.length).toBeGreaterThan(0)
})
})
diff --git a/src/utils/extract-cert.d.ts b/src/utils/extract-cert.d.ts
new file mode 100644
index 0000000..2724900
--- /dev/null
+++ b/src/utils/extract-cert.d.ts
@@ -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
diff --git a/src/utils/extract-cert.js b/src/utils/extract-cert.js
index 55c82ca..840e65c 100644
--- a/src/utils/extract-cert.js
+++ b/src/utils/extract-cert.js
@@ -1,281 +1 @@
-#!/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 [--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()
-}
\ No newline at end of file
+export { extractCertificate, extractCertificateFromText } from './extract-cert.ts'
diff --git a/src/utils/extract-cert.ts b/src/utils/extract-cert.ts
new file mode 100644
index 0000000..da47478
--- /dev/null
+++ b/src/utils/extract-cert.ts
@@ -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)
+}
diff --git a/src/version.js b/src/version.js
index d7d1767..d91f652 100644
--- a/src/version.js
+++ b/src/version.js
@@ -1 +1 @@
-export const VERSION = "V1.0-20260807030735";
\ No newline at end of file
+export const VERSION = "V1.0-20260807054146";
\ No newline at end of file