feat: add Timestamp PDF tool with RFC 3161 support

Add document timestamping to the Secure PDF section using RFC 3161
protocol. Users can select from preset TSA servers (DigiCert, Sectigo,
SSL.com, Entrust, FreeTSA) or enter a custom TSA URL. No personal
certificate is required — only a cryptographic hash is sent to the server.

Key changes:
- Timestamp PDF page with TSA server selector, FAQ and SEO structured data
- timestampPdf() function with CORS proxy URL resolution
- TimestampNode for the workflow engine
- Tool entry in Secure PDF category + homepage i18n
- Built-in CORS proxy middleware for dev/preview
- Translations for all 16 languages

Tested with DigiCert, Sectigo and Entrust TSA servers. Timestamps are
verifiable in Adobe Acrobat (ETSI.RFC3161 SubFilter).
This commit is contained in:
InstalZDLL
2026-03-15 00:30:53 +01:00
parent 2de36b6605
commit dfd0ebcfc5
26 changed files with 1127 additions and 11 deletions

View File

@@ -153,6 +153,20 @@ async function generateProxySignature(
.join('');
}
async function buildCorsProxyUrl(url: string): Promise<string> {
let proxyUrl = `${CORS_PROXY_URL}?url=${encodeURIComponent(url)}`;
if (!CORS_PROXY_SECRET) {
return proxyUrl;
}
const timestamp = Date.now();
const signature = await generateProxySignature(url, timestamp);
proxyUrl += `&t=${timestamp}&sig=${signature}`;
return proxyUrl;
}
/**
* 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
@@ -192,20 +206,30 @@ function createCorsAwareFetch(): {
url.includes('caIssuers')) &&
!url.startsWith(window.location.origin);
if (isExternalCertificateUrl && CORS_PROXY_URL) {
let proxyUrl = `${CORS_PROXY_URL}?url=${encodeURIComponent(url)}`;
const isTsaRequest =
(init?.headers &&
typeof init.headers === 'object' &&
'Content-Type' in init.headers &&
(init.headers as Record<string, string>)['Content-Type'] ===
'application/timestamp-query') ||
url.includes('timestamp') ||
url.includes('/tsa') ||
url.includes('/tsr') ||
url.includes('/ts01') ||
url.includes('RFC3161');
const shouldProxy =
(isExternalCertificateUrl || isTsaRequest) &&
!url.startsWith(window.location.origin);
if (shouldProxy && CORS_PROXY_URL) {
const proxyUrl = await buildCorsProxyUrl(url);
if (CORS_PROXY_SECRET) {
const timestamp = Date.now();
const signature = await generateProxySignature(url, timestamp);
proxyUrl += `&t=${timestamp}&sig=${signature}`;
console.log(
`[CORS Proxy] Routing signed certificate request through proxy: ${url}`
`[CORS Proxy] Routing signed request through proxy: ${url}`
);
} else {
console.log(
`[CORS Proxy] Routing certificate request through proxy: ${url}`
);
console.log(`[CORS Proxy] Routing request through proxy: ${url}`);
}
return originalFetch(proxyUrl, init);
@@ -302,6 +326,34 @@ export async function signPdf(
}
}
export async function timestampPdf(
pdfBytes: Uint8Array,
tsaUrl: string
): Promise<Uint8Array> {
let effectiveUrl = tsaUrl;
if (CORS_PROXY_URL) {
effectiveUrl = await buildCorsProxyUrl(tsaUrl);
if (CORS_PROXY_SECRET) {
console.log(
`[Timestamp] Routing signed TSA request through proxy: ${tsaUrl}`
);
} else {
console.log(`[Timestamp] Routing TSA request through proxy: ${tsaUrl}`);
}
}
const signOptions: SignOption = {
signdate: { url: effectiveUrl },
};
const signer = new PdfSigner(signOptions);
const timestampedPdfBytes = await signer.sign(pdfBytes);
return new Uint8Array(timestampedPdfBytes);
}
export function getCertificateInfo(certificate: forge.pki.Certificate): {
subject: string;
issuer: string;

View File

@@ -0,0 +1,257 @@
import { createIcons, icons } from 'lucide';
import { showAlert, showLoader, hideLoader } from '../ui.js';
import {
readFileAsArrayBuffer,
formatBytes,
downloadFile,
getPDFDocument,
} from '../utils/helpers.js';
import { TIMESTAMP_TSA_PRESETS } from '../config/timestamp-tsa.js';
import { timestampPdf } from './digital-sign-pdf.js';
interface TimestampState {
pdfFile: File | null;
pdfBytes: Uint8Array | null;
}
const state: TimestampState = {
pdfFile: null,
pdfBytes: 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;
const fileDisplayArea = getElement<HTMLDivElement>('file-display-area');
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
const fileInput = getElement<HTMLInputElement>('file-input');
if (fileInput) fileInput.value = '';
const tsaSection = getElement<HTMLDivElement>('tsa-section');
if (tsaSection) tsaSection.classList.add('hidden');
updateProcessButton();
}
function initializePage(): void {
createIcons({ icons });
const fileInput = getElement<HTMLInputElement>('file-input');
const dropZone = getElement<HTMLDivElement>('drop-zone');
const processBtn = getElement<HTMLButtonElement>('process-btn');
const backBtn = getElement<HTMLButtonElement>('back-to-tools');
const tsaPreset = getElement<HTMLSelectElement>('tsa-preset');
populateTsaPresets(tsaPreset);
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 (processBtn) {
processBtn.addEventListener('click', processTimestamp);
}
if (backBtn) {
backBtn.addEventListener('click', () => {
window.location.href = import.meta.env.BASE_URL;
});
}
}
function populateTsaPresets(tsaPreset: HTMLSelectElement | null): void {
if (!tsaPreset) return;
tsaPreset.replaceChildren();
for (const preset of TIMESTAMP_TSA_PRESETS) {
const option = document.createElement('option');
option.value = preset.url;
option.textContent = preset.label;
tsaPreset.append(option);
}
}
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;
}
state.pdfFile = file;
state.pdfBytes = new Uint8Array(
(await readFileAsArrayBuffer(file)) as ArrayBuffer
);
updatePdfDisplay();
showTsaSection();
updateProcessButton();
}
async function updatePdfDisplay(): Promise<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)} • Loading pages...`;
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 = () => {
state.pdfFile = null;
state.pdfBytes = null;
fileDisplayArea.innerHTML = '';
hideTsaSection();
updateProcessButton();
};
fileDiv.append(infoContainer, removeBtn);
fileDisplayArea.appendChild(fileDiv);
createIcons({ icons });
try {
if (state.pdfBytes) {
const pdfDoc = await getPDFDocument({
data: state.pdfBytes.slice(),
}).promise;
metaSpan.textContent = `${formatBytes(state.pdfFile.size)}${pdfDoc.numPages} pages`;
}
} catch (error) {
console.error('Error loading PDF:', error);
metaSpan.textContent = `${formatBytes(state.pdfFile.size)}`;
}
}
function showTsaSection(): void {
const tsaSection = getElement<HTMLDivElement>('tsa-section');
if (tsaSection) {
tsaSection.classList.remove('hidden');
}
}
function hideTsaSection(): void {
const tsaSection = getElement<HTMLDivElement>('tsa-section');
if (tsaSection) {
tsaSection.classList.add('hidden');
}
}
function updateProcessButton(): void {
const processBtn = getElement<HTMLButtonElement>('process-btn');
if (!processBtn) return;
if (state.pdfBytes) {
processBtn.classList.remove('hidden');
processBtn.disabled = false;
} else {
processBtn.classList.add('hidden');
processBtn.disabled = true;
}
}
function getTsaUrl(): string | null {
const tsaPreset = getElement<HTMLSelectElement>('tsa-preset');
if (!tsaPreset) return null;
return tsaPreset.value;
}
async function processTimestamp(): Promise<void> {
if (!state.pdfBytes || !state.pdfFile) {
showAlert('No File', 'Please upload a PDF file first.');
return;
}
const tsaUrl = getTsaUrl();
if (!tsaUrl) return;
showLoader('Applying timestamp...');
try {
const timestampedBytes = await timestampPdf(state.pdfBytes, tsaUrl);
const outputFilename = state.pdfFile.name.replace(
/\.pdf$/i,
'_timestamped.pdf'
);
const blob = new Blob([new Uint8Array(timestampedBytes)], {
type: 'application/pdf',
});
downloadFile(blob, outputFilename);
showAlert(
'Success',
'PDF timestamped successfully! The timestamp can be verified in Adobe Acrobat and other PDF readers.'
);
resetState();
} catch (error) {
console.error('Timestamp error:', error);
const message = error instanceof Error ? error.message : 'Unknown error';
showAlert(
'Timestamp Failed',
`Failed to timestamp PDF: ${message}\n\nPlease try a different TSA server or check your internet connection.`
);
} finally {
hideLoader();
}
}
document.addEventListener('DOMContentLoaded', initializePage);