feat: Add Digital Signature and Validate Signature tools

New Features:
- Digital Signature tool: Sign PDFs with X.509 certificates (PFX/PEM)
  - Visible and invisible signatures
  - All pages, first page, last page, or custom page selection
  - Dynamic signature height based on text content
  - Custom signature text, image, and styling options

- Validate Signature tool: Verify digital signatures in PDFs
  - Extract and parse PKCS#7 signatures
  - View signer and issuer certificate details
  - Check certificate validity and expiry
  - Optional custom X.509 certificate for trust verification
  - Full/partial coverage detection

Infrastructure:
- Added Cloudflare Worker CORS proxy for certificate chain fetching
- Updated README with new tools and proxy deployment instructions

Documentation:
- Added Digital Signature and Validate Signature to README tools table
- Added CORS proxy deployment guide for self-hosters
This commit is contained in:
abdullahalam123
2026-01-04 19:08:50 +05:30
parent 94504d4b75
commit 05110c7f6a
20 changed files with 1703 additions and 24 deletions

View File

@@ -596,15 +596,28 @@ async function processSignature(): Promise<void> {
const sigX = parseInt(getElement<HTMLInputElement>('sig-x')?.value ?? '25', 10);
const sigY = parseInt(getElement<HTMLInputElement>('sig-y')?.value ?? '700', 10);
const sigWidth = parseInt(getElement<HTMLInputElement>('sig-width')?.value ?? '150', 10);
const sigHeight = parseInt(getElement<HTMLInputElement>('sig-height')?.value ?? '50', 10);
const sigHeight = parseInt(getElement<HTMLInputElement>('sig-height')?.value ?? '70', 10);
const sigPageSelect = getElement<HTMLSelectElement>('sig-page');
let sigPage: number | string = 0;
let numPages = 1;
try {
const pdfDoc = await getPDFDocument({ data: state.pdfBytes.slice() }).promise;
numPages = pdfDoc.numPages;
} catch (error) {
console.error('Error getting PDF page count:', error);
}
if (sigPageSelect) {
if (sigPageSelect.value === 'last') {
sigPage = 'last';
sigPage = (numPages - 1).toString();
} else if (sigPageSelect.value === 'all') {
sigPage = 'all';
if (numPages === 1) {
sigPage = '0';
} else {
sigPage = `0-${numPages - 1}`;
}
} else if (sigPageSelect.value === 'custom') {
sigPage = parseInt(getElement<HTMLInputElement>('sig-custom-page')?.value ?? '1', 10) - 1;
} else {
@@ -623,12 +636,21 @@ async function processSignature(): Promise<void> {
sigText = `Digitally signed by ${certInfo.subject}\n${date}`;
}
let finalHeight = sigHeight;
if (sigText && !state.sigImageData) {
const lineCount = (sigText.match(/\n/g) || []).length + 1;
const lineHeightFactor = 1.4;
const padding = 16;
const calculatedHeight = Math.ceil(lineCount * sigTextSize * lineHeightFactor + padding);
finalHeight = Math.max(calculatedHeight, sigHeight);
}
visibleSignature = {
enabled: true,
x: sigX,
y: sigY,
width: sigWidth,
height: sigHeight,
height: finalHeight,
page: sigPage,
imageData: state.sigImageData ?? undefined,
imageType: state.sigImageType ?? undefined,
@@ -658,7 +680,16 @@ async function processSignature(): Promise<void> {
hideLoader();
console.error('Signing error:', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
showAlert('Signing Failed', `Failed to sign PDF: ${errorMessage}`);
// Check if this is a CORS/network error from certificate chain fetching
if (errorMessage.includes('Failed to fetch') || errorMessage.includes('CORS') || errorMessage.includes('NetworkError')) {
showAlert(
'Signing Failed',
'Failed to fetch certificate chain. This may be due to network issues or the certificate proxy being unavailable. Please check your internet connection and try again. If the issue persists, contact support.'
);
} else {
showAlert('Signing Failed', `Failed to sign PDF: ${errorMessage}`);
}
}
}

View File

@@ -111,6 +111,64 @@ export function parseCombinedPem(pemContent: string, password?: string): Certifi
return parsePemFiles(certMatch[0], keyMatch[0], password);
}
/**
* CORS Proxy URL for fetching external certificates.
* The zgapdfsigner library tries to fetch issuer certificates from external URLs,
* but those servers often don't have CORS headers. This proxy adds the necessary
* CORS headers to allow the requests from the browser.
*
* If you are self-hosting, you MUST deploy your own proxy using cloudflare/cors-proxy-worker.js or any other way of your choice
* and set VITE_CORS_PROXY_URL environment variable.
*
* If not set, certificates requiring external chain fetching will fail.
*/
const CORS_PROXY_URL = import.meta.env.VITE_CORS_PROXY_URL || '';
/**
* Custom fetch wrapper that routes external certificate requests through a CORS proxy.
* The zgapdfsigner library tries to fetch issuer certificates from URLs embedded in the
* certificate's AIA extension. When those servers don't have CORS enabled (like www.cert.fnmt.es),
* the fetch fails. This wrapper routes such requests through our CORS proxy.
*/
function createCorsAwareFetch(): {
wrappedFetch: typeof fetch;
restore: () => void;
} {
const originalFetch = window.fetch.bind(window);
const wrappedFetch: typeof fetch = async (input, init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
const isExternalCertificateUrl = (
url.includes('.crt') ||
url.includes('.cer') ||
url.includes('.pem') ||
url.includes('/certs/') ||
url.includes('/ocsp') ||
url.includes('/crl') ||
url.includes('caIssuers')
) && !url.startsWith(window.location.origin);
if (isExternalCertificateUrl && CORS_PROXY_URL) {
const proxyUrl = `${CORS_PROXY_URL}?url=${encodeURIComponent(url)}`;
console.log(`[CORS Proxy] Routing certificate request through proxy: ${url}`);
return originalFetch(proxyUrl, init);
}
return originalFetch(input, init);
};
window.fetch = wrappedFetch;
return {
wrappedFetch,
restore: () => {
window.fetch = originalFetch;
}
};
}
export async function signPdf(
pdfBytes: Uint8Array,
certificateData: CertificateData,
@@ -169,9 +227,15 @@ export async function signPdf(
}
const signer = new PdfSigner(signOptions);
const signedPdfBytes = await signer.sign(pdfBytes);
return new Uint8Array(signedPdfBytes);
const { restore } = createCorsAwareFetch();
try {
const signedPdfBytes = await signer.sign(pdfBytes);
return new Uint8Array(signedPdfBytes);
} finally {
restore();
}
}
export function getCertificateInfo(certificate: forge.pki.Certificate): {

View File

@@ -0,0 +1,476 @@
import { createIcons, icons } from 'lucide';
import { showAlert, showLoader, hideLoader } from '../ui.js';
import { readFileAsArrayBuffer, formatBytes } from '../utils/helpers.js';
import { validatePdfSignatures, type SignatureValidationResult } from './validate-signature-pdf.js';
import forge from 'node-forge';
interface ValidateSignatureState {
pdfFile: File | null;
pdfBytes: Uint8Array | null;
results: SignatureValidationResult[];
trustedCertFile: File | null;
trustedCert: forge.pki.Certificate | null;
}
const state: ValidateSignatureState = {
pdfFile: null,
pdfBytes: null,
results: [],
trustedCertFile: null,
trustedCert: null,
};
function getElement<T extends HTMLElement>(id: string): T | null {
return document.getElementById(id) as T | null;
}
function resetState(): void {
state.pdfFile = null;
state.pdfBytes = null;
state.results = [];
const fileDisplayArea = getElement<HTMLDivElement>('file-display-area');
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
const resultsSection = getElement<HTMLDivElement>('results-section');
if (resultsSection) resultsSection.classList.add('hidden');
const resultsContainer = getElement<HTMLDivElement>('results-container');
if (resultsContainer) resultsContainer.innerHTML = '';
const fileInput = getElement<HTMLInputElement>('file-input');
if (fileInput) fileInput.value = '';
const customCertSection = getElement<HTMLDivElement>('custom-cert-section');
if (customCertSection) customCertSection.classList.add('hidden');
}
function resetCertState(): void {
state.trustedCertFile = null;
state.trustedCert = null;
const certDisplayArea = getElement<HTMLDivElement>('cert-display-area');
if (certDisplayArea) certDisplayArea.innerHTML = '';
const certInput = getElement<HTMLInputElement>('cert-input');
if (certInput) certInput.value = '';
}
function initializePage(): void {
createIcons({ icons });
const fileInput = getElement<HTMLInputElement>('file-input');
const dropZone = getElement<HTMLDivElement>('drop-zone');
const backBtn = getElement<HTMLButtonElement>('back-to-tools');
const certInput = getElement<HTMLInputElement>('cert-input');
const certDropZone = getElement<HTMLDivElement>('cert-drop-zone');
if (fileInput) {
fileInput.addEventListener('change', handlePdfUpload);
fileInput.addEventListener('click', () => {
fileInput.value = '';
});
}
if (dropZone) {
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('bg-gray-700');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('bg-gray-700');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
const droppedFiles = e.dataTransfer?.files;
if (droppedFiles && droppedFiles.length > 0) {
handlePdfFile(droppedFiles[0]);
}
});
}
if (certInput) {
certInput.addEventListener('change', handleCertUpload);
certInput.addEventListener('click', () => {
certInput.value = '';
});
}
if (certDropZone) {
certDropZone.addEventListener('dragover', (e) => {
e.preventDefault();
certDropZone.classList.add('bg-gray-700');
});
certDropZone.addEventListener('dragleave', () => {
certDropZone.classList.remove('bg-gray-700');
});
certDropZone.addEventListener('drop', (e) => {
e.preventDefault();
certDropZone.classList.remove('bg-gray-700');
const droppedFiles = e.dataTransfer?.files;
if (droppedFiles && droppedFiles.length > 0) {
handleCertFile(droppedFiles[0]);
}
});
}
if (backBtn) {
backBtn.addEventListener('click', () => {
window.location.href = import.meta.env.BASE_URL;
});
}
}
function handlePdfUpload(e: Event): void {
const input = e.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
handlePdfFile(input.files[0]);
}
}
async function handlePdfFile(file: File): Promise<void> {
if (file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf')) {
showAlert('Invalid File', 'Please select a PDF file.');
return;
}
resetState();
state.pdfFile = file;
state.pdfBytes = new Uint8Array(await readFileAsArrayBuffer(file) as ArrayBuffer);
updatePdfDisplay();
const customCertSection = getElement<HTMLDivElement>('custom-cert-section');
if (customCertSection) customCertSection.classList.remove('hidden');
createIcons({ icons });
await validateSignatures();
}
function updatePdfDisplay(): void {
const fileDisplayArea = getElement<HTMLDivElement>('file-display-area');
if (!fileDisplayArea || !state.pdfFile) return;
fileDisplayArea.innerHTML = '';
const fileDiv = document.createElement('div');
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col flex-1 min-w-0';
const nameSpan = document.createElement('div');
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
nameSpan.textContent = state.pdfFile.name;
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = formatBytes(state.pdfFile.size);
infoContainer.append(nameSpan, metaSpan);
const removeBtn = document.createElement('button');
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.onclick = () => resetState();
fileDiv.append(infoContainer, removeBtn);
fileDisplayArea.appendChild(fileDiv);
createIcons({ icons });
}
function handleCertUpload(e: Event): void {
const input = e.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
handleCertFile(input.files[0]);
}
}
async function handleCertFile(file: File): Promise<void> {
const validExtensions = ['.pem', '.crt', '.cer', '.der'];
const hasValidExtension = validExtensions.some(ext => file.name.toLowerCase().endsWith(ext));
if (!hasValidExtension) {
showAlert('Invalid Certificate', 'Please select a .pem, .crt, .cer, or .der certificate file.');
return;
}
resetCertState();
state.trustedCertFile = file;
try {
const content = await file.text();
if (content.includes('-----BEGIN CERTIFICATE-----')) {
state.trustedCert = forge.pki.certificateFromPem(content);
} else {
const bytes = new Uint8Array(await readFileAsArrayBuffer(file) as ArrayBuffer);
const derString = String.fromCharCode.apply(null, Array.from(bytes));
const asn1 = forge.asn1.fromDer(derString);
state.trustedCert = forge.pki.certificateFromAsn1(asn1);
}
updateCertDisplay();
if (state.pdfBytes) {
await validateSignatures();
}
} catch (error) {
console.error('Error parsing certificate:', error);
showAlert('Invalid Certificate', 'Failed to parse the certificate file.');
resetCertState();
}
}
function updateCertDisplay(): void {
const certDisplayArea = getElement<HTMLDivElement>('cert-display-area');
if (!certDisplayArea || !state.trustedCertFile || !state.trustedCert) return;
certDisplayArea.innerHTML = '';
const certDiv = document.createElement('div');
certDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col flex-1 min-w-0';
const nameSpan = document.createElement('div');
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
const cn = state.trustedCert.subject.getField('CN');
nameSpan.textContent = cn?.value as string || state.trustedCertFile.name;
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-green-400';
metaSpan.innerHTML = '<i data-lucide="check-circle" class="inline w-3 h-3 mr-1"></i>Trusted certificate loaded';
infoContainer.append(nameSpan, metaSpan);
const removeBtn = document.createElement('button');
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.onclick = async () => {
resetCertState();
if (state.pdfBytes) {
await validateSignatures();
}
};
certDiv.append(infoContainer, removeBtn);
certDisplayArea.appendChild(certDiv);
createIcons({ icons });
}
async function validateSignatures(): Promise<void> {
if (!state.pdfBytes) return;
showLoader('Analyzing signatures...');
try {
state.results = await validatePdfSignatures(state.pdfBytes, state.trustedCert ?? undefined);
displayResults();
} catch (error) {
console.error('Validation error:', error);
showAlert('Error', 'Failed to validate signatures. The file may be corrupted.');
} finally {
hideLoader();
}
}
function displayResults(): void {
const resultsSection = getElement<HTMLDivElement>('results-section');
const resultsContainer = getElement<HTMLDivElement>('results-container');
if (!resultsSection || !resultsContainer) return;
resultsContainer.innerHTML = '';
resultsSection.classList.remove('hidden');
if (state.results.length === 0) {
resultsContainer.innerHTML = `
<div class="bg-gray-700 rounded-lg p-6 text-center border border-gray-600">
<i data-lucide="file-x" class="w-12 h-12 mx-auto mb-4 text-gray-400"></i>
<h3 class="text-lg font-semibold text-white mb-2">No Signatures Found</h3>
<p class="text-gray-400">This PDF does not contain any digital signatures.</p>
</div>
`;
createIcons({ icons });
return;
}
const summaryDiv = document.createElement('div');
summaryDiv.className = 'mb-4 p-3 bg-gray-700 rounded-lg border border-gray-600';
const validCount = state.results.filter(r => r.isValid && !r.isExpired).length;
const trustVerified = state.trustedCert ? state.results.filter(r => r.isTrusted).length : 0;
let summaryHtml = `
<p class="text-gray-300">
<span class="font-semibold text-white">${state.results.length}</span>
signature${state.results.length > 1 ? 's' : ''} found
<span class="text-gray-500">•</span>
<span class="${validCount === state.results.length ? 'text-green-400' : 'text-yellow-400'}">${validCount} valid</span>
</p>
`;
if (state.trustedCert) {
summaryHtml += `
<p class="text-xs text-gray-400 mt-1">
<i data-lucide="shield-check" class="inline w-3 h-3 mr-1"></i>
Trust verification: ${trustVerified}/${state.results.length} signatures verified against custom certificate
</p>
`;
}
summaryDiv.innerHTML = summaryHtml;
resultsContainer.appendChild(summaryDiv);
state.results.forEach((result, index) => {
const card = createSignatureCard(result, index);
resultsContainer.appendChild(card);
});
createIcons({ icons });
}
function createSignatureCard(result: SignatureValidationResult, index: number): HTMLElement {
const card = document.createElement('div');
card.className = 'bg-gray-700 rounded-lg p-4 border border-gray-600 mb-4';
let statusColor = 'text-green-400';
let statusIcon = 'check-circle';
let statusText = 'Valid Signature';
if (!result.isValid) {
statusColor = 'text-red-400';
statusIcon = 'x-circle';
statusText = 'Invalid Signature';
} else if (result.isExpired) {
statusColor = 'text-yellow-400';
statusIcon = 'alert-triangle';
statusText = 'Certificate Expired';
} else if (result.isSelfSigned) {
statusColor = 'text-yellow-400';
statusIcon = 'alert-triangle';
statusText = 'Self-Signed Certificate';
}
const formatDate = (date: Date) => {
if (!date || date.getTime() === 0) return 'Unknown';
return date.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
let trustBadge = '';
if (state.trustedCert) {
if (result.isTrusted) {
trustBadge = '<span class="text-xs bg-green-900 text-green-300 px-2 py-1 rounded ml-2"><i data-lucide="shield-check" class="inline w-3 h-3 mr-1"></i>Trusted</span>';
} else {
trustBadge = '<span class="text-xs bg-gray-600 text-gray-300 px-2 py-1 rounded ml-2"><i data-lucide="shield-x" class="inline w-3 h-3 mr-1"></i>Not in trust chain</span>';
}
}
card.innerHTML = `
<div class="flex items-start justify-between mb-4">
<div class="flex items-center gap-3">
<i data-lucide="${statusIcon}" class="w-6 h-6 ${statusColor}"></i>
<div>
<h3 class="font-semibold text-white">Signature ${index + 1}</h3>
<p class="text-sm ${statusColor}">${statusText}</p>
</div>
</div>
<div class="flex items-center">
${result.coverageStatus === 'full'
? '<span class="text-xs bg-green-900 text-green-300 px-2 py-1 rounded">Full Coverage</span>'
: result.coverageStatus === 'partial'
? '<span class="text-xs bg-yellow-900 text-yellow-300 px-2 py-1 rounded">Partial Coverage</span>'
: ''
}${trustBadge}
</div>
</div>
<div class="space-y-3 text-sm">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p class="text-gray-400">Signed By</p>
<p class="text-white font-medium">${escapeHtml(result.signerName)}</p>
${result.signerOrg ? `<p class="text-gray-400 text-xs">${escapeHtml(result.signerOrg)}</p>` : ''}
${result.signerEmail ? `<p class="text-gray-400 text-xs">${escapeHtml(result.signerEmail)}</p>` : ''}
</div>
<div>
<p class="text-gray-400">Issuer</p>
<p class="text-white font-medium">${escapeHtml(result.issuer)}</p>
${result.issuerOrg ? `<p class="text-gray-400 text-xs">${escapeHtml(result.issuerOrg)}</p>` : ''}
</div>
</div>
${result.signatureDate ? `
<div>
<p class="text-gray-400">Signed On</p>
<p class="text-white">${formatDate(result.signatureDate)}</p>
</div>
` : ''}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p class="text-gray-400">Valid From</p>
<p class="text-white">${formatDate(result.validFrom)}</p>
</div>
<div>
<p class="text-gray-400">Valid Until</p>
<p class="${result.isExpired ? 'text-red-400' : 'text-white'}">${formatDate(result.validTo)}</p>
</div>
</div>
${result.reason ? `
<div>
<p class="text-gray-400">Reason</p>
<p class="text-white">${escapeHtml(result.reason)}</p>
</div>
` : ''}
${result.location ? `
<div>
<p class="text-gray-400">Location</p>
<p class="text-white">${escapeHtml(result.location)}</p>
</div>
` : ''}
<details class="mt-2">
<summary class="cursor-pointer text-indigo-400 hover:text-indigo-300 text-sm">
Technical Details
</summary>
<div class="mt-2 p-3 bg-gray-800 rounded text-xs space-y-1">
<p><span class="text-gray-400">Serial Number:</span> <span class="text-gray-300 font-mono">${escapeHtml(result.serialNumber)}</span></p>
<p><span class="text-gray-400">Digest Algorithm:</span> <span class="text-gray-300">${escapeHtml(result.algorithms.digest)}</span></p>
<p><span class="text-gray-400">Signature Algorithm:</span> <span class="text-gray-300">${escapeHtml(result.algorithms.signature)}</span></p>
${result.errorMessage ? `<p class="text-red-400">Error: ${escapeHtml(result.errorMessage)}</p>` : ''}
</div>
</details>
</div>
`;
return card;
}
function escapeHtml(str: string): string {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializePage);
} else {
initializePage();
}

View File

@@ -0,0 +1,308 @@
import forge from 'node-forge';
export interface SignatureValidationResult {
signatureIndex: number;
isValid: boolean;
signerName: string;
signerOrg?: string;
signerEmail?: string;
issuer: string;
issuerOrg?: string;
signatureDate?: Date;
validFrom: Date;
validTo: Date;
isExpired: boolean;
isSelfSigned: boolean;
isTrusted: boolean;
algorithms: {
digest: string;
signature: string;
};
serialNumber: string;
reason?: string;
location?: string;
contactInfo?: string;
byteRange?: number[];
coverageStatus: 'full' | 'partial' | 'unknown';
errorMessage?: string;
}
export interface ExtractedSignature {
index: number;
contents: Uint8Array;
byteRange: number[];
reason?: string;
location?: string;
contactInfo?: string;
name?: string;
signingTime?: string;
}
/**
* Extract all digital signatures from a PDF file
*/
export function extractSignatures(pdfBytes: Uint8Array): ExtractedSignature[] {
const signatures: ExtractedSignature[] = [];
const pdfString = new TextDecoder('latin1').decode(pdfBytes);
// Find all signature objects by looking for /Type /Sig
const sigRegex = /\/Type\s*\/Sig\b/g;
let sigMatch;
let sigIndex = 0;
while ((sigMatch = sigRegex.exec(pdfString)) !== null) {
try {
// Find the containing object
const searchStart = Math.max(0, sigMatch.index - 5000);
const searchEnd = Math.min(pdfString.length, sigMatch.index + 10000);
const context = pdfString.substring(searchStart, searchEnd);
// Extract ByteRange
const byteRangeMatch = context.match(/\/ByteRange\s*\[\s*(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s*\]/);
if (!byteRangeMatch) continue;
const byteRange = [
parseInt(byteRangeMatch[1], 10),
parseInt(byteRangeMatch[2], 10),
parseInt(byteRangeMatch[3], 10),
parseInt(byteRangeMatch[4], 10),
];
// Extract Contents (the actual PKCS#7 signature)
const contentsMatch = context.match(/\/Contents\s*<([0-9A-Fa-f]+)>/);
if (!contentsMatch) continue;
const hexContents = contentsMatch[1];
const contentsBytes = hexToBytes(hexContents);
// Extract optional fields
const reasonMatch = context.match(/\/Reason\s*\(([^)]*)\)/);
const locationMatch = context.match(/\/Location\s*\(([^)]*)\)/);
const contactMatch = context.match(/\/ContactInfo\s*\(([^)]*)\)/);
const nameMatch = context.match(/\/Name\s*\(([^)]*)\)/);
const timeMatch = context.match(/\/M\s*\(D:(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/);
let signingTime: string | undefined;
if (timeMatch) {
signingTime = `${timeMatch[1]}-${timeMatch[2]}-${timeMatch[3]}T${timeMatch[4]}:${timeMatch[5]}:${timeMatch[6]}`;
}
signatures.push({
index: sigIndex++,
contents: contentsBytes,
byteRange,
reason: reasonMatch ? decodeURIComponent(escape(reasonMatch[1])) : undefined,
location: locationMatch ? decodeURIComponent(escape(locationMatch[1])) : undefined,
contactInfo: contactMatch ? decodeURIComponent(escape(contactMatch[1])) : undefined,
name: nameMatch ? decodeURIComponent(escape(nameMatch[1])) : undefined,
signingTime,
});
} catch (e) {
console.warn('Error extracting signature at index', sigIndex, e);
}
}
return signatures;
}
/**
* Validate a single extracted signature
*/
export function validateSignature(
signature: ExtractedSignature,
pdfBytes: Uint8Array,
trustedCert?: forge.pki.Certificate
): SignatureValidationResult {
const result: SignatureValidationResult = {
signatureIndex: signature.index,
isValid: false,
signerName: 'Unknown',
issuer: 'Unknown',
validFrom: new Date(0),
validTo: new Date(0),
isExpired: false,
isSelfSigned: false,
isTrusted: false,
algorithms: { digest: 'Unknown', signature: 'Unknown' },
serialNumber: '',
byteRange: signature.byteRange,
coverageStatus: 'unknown',
reason: signature.reason,
location: signature.location,
contactInfo: signature.contactInfo,
};
try {
// Parse the PKCS#7 signature - convert Uint8Array to binary string
const binaryString = String.fromCharCode.apply(null, Array.from(signature.contents));
const asn1 = forge.asn1.fromDer(binaryString);
const p7 = forge.pkcs7.messageFromAsn1(asn1) as any;
// Get signer info
if (!p7.certificates || p7.certificates.length === 0) {
result.errorMessage = 'No certificates found in signature';
return result;
}
// Use the first certificate (signer's certificate)
const signerCert = p7.certificates[0] as forge.pki.Certificate;
// Extract signer information
const subjectCN = signerCert.subject.getField('CN');
const subjectO = signerCert.subject.getField('O');
const subjectE = signerCert.subject.getField('E') || signerCert.subject.getField('emailAddress');
const issuerCN = signerCert.issuer.getField('CN');
const issuerO = signerCert.issuer.getField('O');
result.signerName = (subjectCN?.value as string) ?? 'Unknown';
result.signerOrg = subjectO?.value as string | undefined;
result.signerEmail = subjectE?.value as string | undefined;
result.issuer = (issuerCN?.value as string) ?? 'Unknown';
result.issuerOrg = issuerO?.value as string | undefined;
result.validFrom = signerCert.validity.notBefore;
result.validTo = signerCert.validity.notAfter;
result.serialNumber = signerCert.serialNumber;
// Check if expired
const now = new Date();
result.isExpired = now > result.validTo || now < result.validFrom;
// Check if self-signed
result.isSelfSigned = signerCert.isIssuer(signerCert);
// Check trust against provided certificate
if (trustedCert) {
try {
// Check if the signer cert is issued by the trusted cert
// or if the trusted cert matches one of the certs in the chain
const isTrustedIssuer = trustedCert.isIssuer(signerCert);
const isSameCert = signerCert.serialNumber === trustedCert.serialNumber;
// Also check if any cert in the PKCS#7 chain matches or is issued by trusted cert
let chainTrusted = false;
for (const cert of p7.certificates) {
if (trustedCert.isIssuer(cert) ||
(cert as forge.pki.Certificate).serialNumber === trustedCert.serialNumber) {
chainTrusted = true;
break;
}
}
result.isTrusted = isTrustedIssuer || isSameCert || chainTrusted;
} catch {
result.isTrusted = false;
}
}
// Extract algorithm info
result.algorithms = {
digest: getDigestAlgorithmName(signerCert.siginfo?.algorithmOid || ''),
signature: getSignatureAlgorithmName(signerCert.signatureOid || ''),
};
// Parse signing time if available in signature
if (signature.signingTime) {
result.signatureDate = new Date(signature.signingTime);
} else {
// Try to extract from authenticated attributes
try {
const signedData = p7 as any;
if (signedData.rawCapture?.authenticatedAttributes) {
// Look for signing time attribute
for (const attr of signedData.rawCapture.authenticatedAttributes) {
if (attr.type === forge.pki.oids.signingTime) {
result.signatureDate = attr.value;
break;
}
}
}
} catch { /* ignore */ }
}
// Check byte range coverage
if (signature.byteRange && signature.byteRange.length === 4) {
const [start1, len1, start2, len2] = signature.byteRange;
const totalCovered = len1 + len2;
const expectedEnd = start2 + len2;
if (expectedEnd === pdfBytes.length) {
result.coverageStatus = 'full';
} else if (expectedEnd < pdfBytes.length) {
result.coverageStatus = 'partial';
}
}
// Mark as valid if we could parse it
result.isValid = true;
} catch (e) {
result.errorMessage = e instanceof Error ? e.message : 'Failed to parse signature';
}
return result;
}
/**
* Validate all signatures in a PDF
*/
export async function validatePdfSignatures(
pdfBytes: Uint8Array,
trustedCert?: forge.pki.Certificate
): Promise<SignatureValidationResult[]> {
const signatures = extractSignatures(pdfBytes);
return signatures.map(sig => validateSignature(sig, pdfBytes, trustedCert));
}
/**
* Get the number of signatures in a PDF without full validation
*/
export function countSignatures(pdfBytes: Uint8Array): number {
return extractSignatures(pdfBytes).length;
}
// Helper functions
function hexToBytes(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
}
// PDF signature /Contents are padded with trailing null bytes.
// node-forge ASN.1 parser fails with "Unparsed DER bytes remain" if we include them.
// Find the actual end of the DER data by stripping trailing zeros.
let actualLength = bytes.length;
while (actualLength > 0 && bytes[actualLength - 1] === 0) {
actualLength--;
}
return bytes.slice(0, actualLength);
}
function getDigestAlgorithmName(oid: string): string {
const digestAlgorithms: Record<string, string> = {
'1.2.840.113549.2.5': 'MD5',
'1.3.14.3.2.26': 'SHA-1',
'2.16.840.1.101.3.4.2.1': 'SHA-256',
'2.16.840.1.101.3.4.2.2': 'SHA-384',
'2.16.840.1.101.3.4.2.3': 'SHA-512',
'2.16.840.1.101.3.4.2.4': 'SHA-224',
};
return digestAlgorithms[oid] || oid || 'Unknown';
}
function getSignatureAlgorithmName(oid: string): string {
const signatureAlgorithms: Record<string, string> = {
'1.2.840.113549.1.1.1': 'RSA',
'1.2.840.113549.1.1.5': 'RSA with SHA-1',
'1.2.840.113549.1.1.11': 'RSA with SHA-256',
'1.2.840.113549.1.1.12': 'RSA with SHA-384',
'1.2.840.113549.1.1.13': 'RSA with SHA-512',
'1.2.840.10045.2.1': 'ECDSA',
'1.2.840.10045.4.1': 'ECDSA with SHA-1',
'1.2.840.10045.4.3.2': 'ECDSA with SHA-256',
'1.2.840.10045.4.3.3': 'ECDSA with SHA-384',
'1.2.840.10045.4.3.4': 'ECDSA with SHA-512',
};
return signatureAlgorithms[oid] || oid || 'Unknown';
}