Merge pull request #576 from InstaZDLL/feat/timestamp-pdf

feat: add Timestamp PDF tool with RFC 3161 support
This commit is contained in:
Alam
2026-03-27 11:03:57 +05:30
committed by GitHub
31 changed files with 1644 additions and 11 deletions

View File

@@ -0,0 +1,14 @@
export interface TimestampTsaPreset {
label: string;
url: string;
}
// Some TSA providers only expose HTTP endpoints. RFC 3161 timestamp tokens are
// signed at the application layer, so integrity does not depend solely on TLS.
export const TIMESTAMP_TSA_PRESETS: TimestampTsaPreset[] = [
{ label: 'DigiCert', url: 'http://timestamp.digicert.com' },
{ label: 'Sectigo', url: 'http://timestamp.sectigo.com' },
{ label: 'SSL.com', url: 'http://ts.ssl.com' },
{ label: 'FreeTSA', url: 'https://freetsa.org/tsr' },
{ label: 'MeSign', url: 'http://tsa.mesign.com' },
];

View File

@@ -799,6 +799,13 @@ const baseCategories = [
icon: 'ph-seal-check',
subtitle: 'Verify digital signatures and view certificate details.',
},
{
href: import.meta.env.BASE_URL + 'timestamp-pdf.html',
name: 'Timestamp PDF',
icon: 'ph-clock',
subtitle:
'Add an RFC 3161 document timestamp using a trusted TSA server.',
},
],
},
];

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,32 @@ 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 &&
(init.headers instanceof Headers
? init.headers.get('Content-Type') === 'application/timestamp-query'
: typeof init.headers === 'object' &&
!Array.isArray(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 +328,40 @@ 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 { restore } = createCorsAwareFetch();
try {
const timestampedPdfBytes = await signer.sign(pdfBytes);
return new Uint8Array(timestampedPdfBytes);
} finally {
restore();
}
}
export function getCertificateInfo(certificate: forge.pki.Certificate): {
subject: string;
issuer: string;

View File

@@ -0,0 +1,258 @@
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.',
'success'
);
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);

View File

@@ -216,6 +216,7 @@ const init = async () => {
'Deskew PDF': 'tools:deskewPdf',
'Digital Signature': 'tools:digitalSignPdf',
'Validate Signature': 'tools:validateSignaturePdf',
'Timestamp PDF': 'tools:timestampPdf',
'Scanner Effect': 'tools:scannerEffect',
'Adjust Colors': 'tools:adjustColors',
'Markdown to PDF': 'tools:markdownToPdf',

View File

@@ -35,6 +35,7 @@ import { SanitizeNode } from './sanitize-node';
import { EncryptNode } from './encrypt-node';
import { DecryptNode } from './decrypt-node';
import { DigitalSignNode } from './digital-sign-node';
import { TimestampNode } from './timestamp-node';
import { RedactNode } from './redact-node';
import { RepairNode } from './repair-node';
import { PdfToTextNode } from './pdf-to-text-node';
@@ -509,6 +510,13 @@ export const nodeRegistry: Record<string, NodeRegistryEntry> = {
description: 'Apply a digital signature to PDF',
factory: () => new DigitalSignNode(),
},
TimestampNode: {
label: 'Timestamp',
category: 'Secure PDF',
icon: 'ph-clock',
description: 'Add an RFC 3161 document timestamp',
factory: () => new TimestampNode(),
},
RedactNode: {
label: 'Redact',
category: 'Secure PDF',

View File

@@ -0,0 +1,66 @@
import { ClassicPreset } from 'rete';
import { BaseWorkflowNode } from './base-node';
import { pdfSocket } from '../sockets';
import type { SocketData } from '../types';
import { requirePdfInput, processBatch } from '../types';
import { PDFDocument } from 'pdf-lib';
import { TIMESTAMP_TSA_PRESETS } from '../../config/timestamp-tsa.js';
import { timestampPdf } from '../../logic/digital-sign-pdf.js';
export class TimestampNode extends BaseWorkflowNode {
readonly category = 'Secure PDF' as const;
readonly icon = 'ph-clock';
readonly description = 'Add an RFC 3161 document timestamp';
constructor() {
super('Timestamp');
this.addInput('pdf', new ClassicPreset.Input(pdfSocket, 'PDF'));
this.addOutput(
'pdf',
new ClassicPreset.Output(pdfSocket, 'Timestamped PDF')
);
this.addControl(
'tsaUrl',
new ClassicPreset.InputControl('text', {
initial: TIMESTAMP_TSA_PRESETS[0].url,
})
);
}
getTsaPresets(): { label: string; url: string }[] {
return TIMESTAMP_TSA_PRESETS;
}
async data(
inputs: Record<string, SocketData[]>
): Promise<Record<string, SocketData>> {
const pdfInputs = requirePdfInput(inputs, 'Timestamp');
const tsaUrlCtrl = this.controls['tsaUrl'] as
| ClassicPreset.InputControl<'text'>
| undefined;
const tsaUrl = tsaUrlCtrl?.value || TIMESTAMP_TSA_PRESETS[0].url;
return {
pdf: await processBatch(pdfInputs, async (input) => {
let bytes: Uint8Array;
try {
bytes = await timestampPdf(input.bytes, tsaUrl);
} catch (err) {
throw new Error(
`Failed to timestamp using TSA ${tsaUrl}: ${err instanceof Error ? err.message : err}`,
{ cause: err }
);
}
const document = await PDFDocument.load(bytes);
return {
type: 'pdf',
document,
bytes,
filename: input.filename.replace(/\.pdf$/i, '_timestamped.pdf'),
};
}),
};
}
}

View File

@@ -0,0 +1,492 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>
Timestamp PDF Online Free - RFC 3161 Document Timestamp | BentoPDF
</title>
<meta
name="title"
content="Timestamp PDF Online Free - RFC 3161 Document Timestamp | BentoPDF"
/>
<meta
name="description"
content="★ Add RFC 3161 document timestamp to PDF online free ★ Trusted TSA servers ★ No certificate required ★ Privacy-first ★ Works in browser"
/>
<meta
name="keywords"
content="timestamp pdf, rfc 3161, tsa server, document timestamp, time stamp authority, pdf timestamp"
/>
<meta name="author" content="BentoPDF" />
<meta
name="robots"
content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1"
/>
<link rel="canonical" href="https://www.bentopdf.com/timestamp-pdf.html" />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://www.bentopdf.com/timestamp-pdf" />
<meta
property="og:title"
content="Timestamp PDF Online Free - RFC 3161 Document Timestamp | BentoPDF"
/>
<meta
property="og:description"
content="★ Add RFC 3161 document timestamp to PDF online free ★ Trusted TSA servers ★ No certificate required ★ Privacy-first ★ Works in browser"
/>
<meta
property="og:image"
content="https://www.bentopdf.com/images/og-timestamp-pdf.png"
/>
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:site_name" content="BentoPDF" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:url" content="https://www.bentopdf.com/timestamp-pdf" />
<meta name="twitter:title" content="Timestamp PDF Free" />
<meta
name="twitter:description"
content="★ Add RFC 3161 document timestamp to PDF online free ★ Trusted TSA servers ★ Privacy-first"
/>
<meta
name="twitter:image"
content="https://www.bentopdf.com/images/twitter-timestamp-pdf.png"
/>
<meta name="twitter:site" content="@BentoPDF" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="Timestamp PDF" />
<link href="/src/css/styles.css" rel="stylesheet" />
<link rel="manifest" href="/site.webmanifest" />
<link rel="icon" type="image/svg+xml" href="/images/favicon.svg" />
<link
rel="icon"
type="image/png"
sizes="192x192"
href="/images/favicon-192x192.png"
/>
<link
rel="icon"
type="image/png"
sizes="512x512"
href="/images/favicon-512x512.png"
/>
<link
rel="apple-touch-icon"
sizes="180x180"
href="/images/apple-touch-icon.png"
/>
<link rel="icon" href="/favicon.ico" sizes="32x32" />
</head>
<body class="antialiased bg-gray-900">
{{> navbar }}
<div
id="uploader"
class="min-h-screen flex flex-col items-center justify-start py-12 p-4 bg-gray-900"
>
<div
id="tool-uploader"
class="bg-gray-800 rounded-xl shadow-xl px-4 py-8 md:p-8 max-w-2xl w-full text-gray-200 border border-gray-700"
>
<button
id="back-to-tools"
class="flex items-center gap-2 text-indigo-400 hover:text-indigo-300 mb-6 font-semibold"
>
<i data-lucide="arrow-left" class="cursor-pointer"></i>
<span class="cursor-pointer" data-i18n="tools.backToTools">
Back to Tools
</span>
</button>
<h1
class="text-2xl font-bold text-white mb-2"
data-i18n="tools:timestampPdf.name"
>
Timestamp PDF
</h1>
<p class="text-gray-400 mb-4" data-i18n="tools:timestampPdf.subtitle">
Add an RFC 3161 document timestamp to your PDF using a trusted Time
Stamp Authority (TSA) server. Proves your document existed at a
specific point in time. No certificate required.
</p>
<div
class="bg-blue-900/30 border border-blue-700 rounded-lg p-3 mb-6 flex items-start gap-3"
>
<i
data-lucide="wifi"
class="w-5 h-5 text-blue-400 flex-shrink-0 mt-0.5"
></i>
<div class="text-sm">
<p class="text-blue-300 font-medium">
Internet connection required
</p>
<p class="text-blue-400/80 mt-1">
Timestamping requires contacting the selected TSA server to obtain
a trusted timestamp token.
</p>
</div>
</div>
<div
id="drop-zone"
class="relative flex flex-col items-center justify-center w-full h-48 md:h-64 border-2 border-dashed border-gray-600 rounded-xl cursor-pointer bg-gray-900 hover:bg-gray-700 transition-colors duration-300"
>
<div class="flex flex-col items-center justify-center pt-5 pb-6">
<i
data-lucide="upload-cloud"
class="w-10 h-10 mb-3 text-gray-400"
></i>
<p class="mb-2 text-sm text-gray-400">
<span class="font-semibold" data-i18n="upload.clickToSelect"
>Click to select a file</span
>
<span data-i18n="upload.orDragAndDrop">or drag and drop</span>
</p>
<p
class="text-xs text-gray-500"
data-i18n="upload.hints.pdfDocuments"
>
PDF Documents
</p>
<p class="text-xs text-gray-500" data-i18n="upload.filesNeverLeave">
Your files never leave your device.
</p>
</div>
<input
id="file-input"
type="file"
class="absolute top-0 left-0 w-full h-full opacity-0 cursor-pointer"
accept="application/pdf"
/>
</div>
<div id="file-display-area" class="mt-4 space-y-2"></div>
<div id="tsa-section" class="hidden mt-6 space-y-4">
<h3
class="text-lg font-semibold text-white"
data-i18n="tools:timestampPdf.tsaSectionTitle"
>
Timestamp Server (TSA)
</h3>
<div>
<label
for="tsa-preset"
class="block text-sm font-medium text-gray-300 mb-1"
data-i18n="tools:timestampPdf.selectTsa"
>
Select a TSA server
</label>
<select
id="tsa-preset"
class="w-full bg-gray-700 border border-gray-600 text-gray-200 rounded-lg px-3 py-2 focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
></select>
</div>
</div>
<button
id="process-btn"
class="hidden w-full mt-6 bg-indigo-600 hover:bg-indigo-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white font-semibold py-3 px-4 rounded-lg transition-colors duration-200 flex items-center justify-center gap-2"
disabled
>
<i data-lucide="clock" class="w-5 h-5"></i>
<span data-i18n="tools:timestampPdf.applyTimestamp"
>Apply Timestamp</span
>
</button>
</div>
</div>
<div
id="loader-overlay"
class="hidden fixed inset-0 bg-black/60 z-50 flex items-center justify-center"
>
<div class="text-center">
<div
class="inline-block animate-spin rounded-full h-12 w-12 border-4 border-indigo-400 border-t-transparent mb-4"
></div>
<p id="loader-text" class="text-white text-lg font-medium">
Processing...
</p>
</div>
</div>
<div
id="alert-overlay"
class="hidden fixed inset-0 bg-black/60 z-50 flex items-center justify-center"
>
<div
class="bg-gray-800 rounded-xl shadow-xl p-6 max-w-md w-full mx-4 border border-gray-700"
>
<h3
id="alert-title"
class="text-xl font-bold text-white mb-2"
data-i18n="alert.title"
>
Alert
</h3>
<p id="alert-message" class="text-gray-300 mb-6"></p>
<button
id="alert-ok"
class="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-semibold py-2 px-4 rounded-lg transition-colors duration-200"
>
OK
</button>
</div>
</div>
<section class="max-w-4xl mx-auto px-4 py-12">
<h2
class="text-2xl md:text-3xl font-bold text-white mb-8 text-center"
data-i18n="howItWorks.title"
>
How It Works
</h2>
<div class="space-y-6">
<div class="flex items-start gap-4">
<div
class="flex-shrink-0 w-10 h-10 bg-indigo-600 rounded-full flex items-center justify-center text-white font-bold"
>
1
</div>
<div class="flex-1">
<h3 class="text-lg font-semibold text-white mb-1">Upload PDF</h3>
<p class="text-gray-400">
Select the PDF document you want to timestamp
</p>
</div>
</div>
<div class="flex items-start gap-4">
<div
class="flex-shrink-0 w-10 h-10 bg-indigo-600 rounded-full flex items-center justify-center text-white font-bold"
>
2
</div>
<div class="flex-1">
<h3 class="text-lg font-semibold text-white mb-1">
Choose TSA Server
</h3>
<p class="text-gray-400">Select one of the trusted TSA servers</p>
</div>
</div>
<div class="flex items-start gap-4">
<div
class="flex-shrink-0 w-10 h-10 bg-indigo-600 rounded-full flex items-center justify-center text-white font-bold"
>
3
</div>
<div class="flex-1">
<h3 class="text-lg font-semibold text-white mb-1">
Timestamp & Download
</h3>
<p class="text-gray-400">
Apply the RFC 3161 timestamp and download your timestamped PDF
</p>
</div>
</div>
</div>
</section>
<section class="max-w-6xl mx-auto px-4 py-12">
<h2
class="text-2xl md:text-3xl font-bold text-white mb-6 text-center"
data-i18n="relatedTools.title"
>
Related PDF Tools
</h2>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
<a
href="digital-sign-pdf.html"
class="block bg-gray-800 p-4 rounded-lg hover:bg-gray-700 transition-colors border border-gray-700"
>
<h3 class="text-white font-semibold mb-1">Digital Signature</h3>
<p class="text-gray-400 text-sm">Sign with X.509 certificate</p>
</a>
<a
href="validate-signature-pdf.html"
class="block bg-gray-800 p-4 rounded-lg hover:bg-gray-700 transition-colors border border-gray-700"
>
<h3 class="text-white font-semibold mb-1">Validate Signature</h3>
<p class="text-gray-400 text-sm">Verify digital signatures</p>
</a>
<a
href="encrypt-pdf.html"
class="block bg-gray-800 p-4 rounded-lg hover:bg-gray-700 transition-colors border border-gray-700"
>
<h3 class="text-white font-semibold mb-1">Encrypt PDF</h3>
<p class="text-gray-400 text-sm">Password protect your PDF</p>
</a>
<a
href="flatten-pdf.html"
class="block bg-gray-800 p-4 rounded-lg hover:bg-gray-700 transition-colors border border-gray-700"
>
<h3 class="text-white font-semibold mb-1">Flatten PDF</h3>
<p class="text-gray-400 text-sm">Make annotations permanent</p>
</a>
<a
href="sanitize-pdf.html"
class="block bg-gray-800 p-4 rounded-lg hover:bg-gray-700 transition-colors border border-gray-700"
>
<h3 class="text-white font-semibold mb-1">Sanitize PDF</h3>
<p class="text-gray-400 text-sm">Remove hidden data</p>
</a>
</div>
</section>
<section class="max-w-4xl mx-auto px-4 py-12">
<h2
class="text-2xl md:text-3xl font-bold text-white mb-6 text-center"
data-i18n="faq.sectionTitle"
>
Frequently Asked Questions
</h2>
<div class="space-y-4">
<details class="bg-gray-800 p-5 rounded-lg border border-gray-700">
<summary
class="cursor-pointer font-semibold text-white flex items-center justify-between"
>
What is a document timestamp?
<i data-lucide="chevron-down" class="w-5 h-5"></i>
</summary>
<p class="mt-3 text-gray-400">
A document timestamp (RFC 3161) is a cryptographic proof that your
PDF existed at a specific point in time. A trusted Time Stamp
Authority (TSA) server signs a hash of your document along with the
current time, creating a tamper-evident timestamp embedded in the
PDF.
</p>
</details>
<details class="bg-gray-800 p-5 rounded-lg border border-gray-700">
<summary
class="cursor-pointer font-semibold text-white flex items-center justify-between"
>
Do I need a certificate?
<i data-lucide="chevron-down" class="w-5 h-5"></i>
</summary>
<p class="mt-3 text-gray-400">
No. Unlike a digital signature, a document timestamp does not
require a personal certificate. The TSA server provides the trusted
timestamp using its own certificate.
</p>
</details>
<details class="bg-gray-800 p-5 rounded-lg border border-gray-700">
<summary
class="cursor-pointer font-semibold text-white flex items-center justify-between"
>
Is my document sent to the TSA server?
<i data-lucide="chevron-down" class="w-5 h-5"></i>
</summary>
<p class="mt-3 text-gray-400">
No. Only a cryptographic hash (SHA-256) of your document is sent to
the TSA server. Your actual PDF content never leaves your browser.
</p>
</details>
<details class="bg-gray-800 p-5 rounded-lg border border-gray-700">
<summary
class="cursor-pointer font-semibold text-white flex items-center justify-between"
>
Will the timestamp be valid in PDF readers?
<i data-lucide="chevron-down" class="w-5 h-5"></i>
</summary>
<p class="mt-3 text-gray-400">
Yes. The tool creates a standard RFC 3161 document timestamp
(ETSI.RFC3161 SubFilter) that is recognized by Adobe Acrobat and
other major PDF viewers.
</p>
</details>
</div>
</section>
{{> footer }}
<script type="module" src="/src/js/utils/lucide-init.ts"></script>
<script type="module" src="/src/js/utils/full-width.ts"></script>
<script type="module" src="/src/js/utils/simple-mode-footer.ts"></script>
<script type="module" src="/src/version.ts"></script>
<script type="module" src="/src/js/logic/timestamp-pdf-page.ts"></script>
<script type="module" src="/src/js/mobileMenu.ts"></script>
<script type="module" src="/src/js/main.ts"></script>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"name": "Timestamp PDF - BentoPDF",
"applicationCategory": "PDF Tool",
"operatingSystem": "Any - Web Browser",
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "USD"
},
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": "4.9",
"ratingCount": "1850"
}
}
</script>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "HowTo",
"name": "How to add a timestamp to PDF",
"description": "Learn how to add an RFC 3161 document timestamp to PDF using BentoPDF",
"step": [
{
"@type": "HowToStep",
"position": 1,
"name": "Upload PDF",
"text": "Select the PDF document you want to timestamp"
},
{
"@type": "HowToStep",
"position": 2,
"name": "Choose TSA Server",
"text": "Select one of the trusted TSA servers"
},
{
"@type": "HowToStep",
"position": 3,
"name": "Timestamp & Download",
"text": "Apply the RFC 3161 timestamp and download your timestamped PDF"
}
]
}
</script>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "Home",
"item": "https://www.bentopdf.com"
},
{
"@type": "ListItem",
"position": 2,
"name": "Timestamp PDF",
"item": "https://www.bentopdf.com/timestamp-pdf"
}
]
}
</script>
</body>
</html>

View File

@@ -0,0 +1,115 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
const mockSign = vi.fn();
vi.mock('zgapdfsigner', () => {
const MockPdfSigner = vi.fn(function (this: { sign: typeof mockSign }) {
this.sign = mockSign;
});
return { PdfSigner: MockPdfSigner };
});
import { PdfSigner } from 'zgapdfsigner';
import { timestampPdf } from '@/js/logic/digital-sign-pdf';
const SAMPLE_PDF_PATH = path.resolve(__dirname, './fixtures/sample.pdf');
const SAMPLE_PDF_SHA256 =
'229defbb0cee6f02673a5cde290d0673e75a0dc31cec43989c8ab2a4eca7e1bb';
async function sha256(data: Uint8Array): Promise<string> {
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
return Array.from(new Uint8Array(hashBuffer))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
describe('timestampPdf', () => {
let samplePdfBytes: Uint8Array;
beforeEach(() => {
vi.clearAllMocks();
vi.stubEnv('VITE_CORS_PROXY_URL', '');
vi.stubEnv('VITE_CORS_PROXY_SECRET', '');
samplePdfBytes = new Uint8Array(fs.readFileSync(SAMPLE_PDF_PATH));
});
it('should load the correct sample PDF', async () => {
const hash = await sha256(samplePdfBytes);
expect(hash).toBe(SAMPLE_PDF_SHA256);
});
it('should call PdfSigner with signdate option containing the TSA URL', async () => {
const fakeSigned = new Uint8Array([80, 68, 70, 45, 49, 46, 52]); // "PDF-1.4"
mockSign.mockResolvedValueOnce(fakeSigned);
const tsaUrl = 'http://timestamp.digicert.com';
await timestampPdf(samplePdfBytes, tsaUrl);
expect(PdfSigner).toHaveBeenCalledWith({
signdate: { url: tsaUrl },
});
});
it('should pass the PDF bytes to signer.sign()', async () => {
const fakeSigned = new Uint8Array([1, 2, 3]);
mockSign.mockResolvedValueOnce(fakeSigned);
const tsaUrl = 'http://timestamp.digicert.com';
await timestampPdf(samplePdfBytes, tsaUrl);
expect(mockSign).toHaveBeenCalledOnce();
const passedBytes = mockSign.mock.calls[0][0];
expect(passedBytes).toBeInstanceOf(Uint8Array);
expect(passedBytes.length).toBe(samplePdfBytes.length);
});
it('should return a Uint8Array from the signed result', async () => {
const fakeSigned = new Uint8Array([10, 20, 30, 40]);
mockSign.mockResolvedValueOnce(fakeSigned);
const result = await timestampPdf(samplePdfBytes, 'http://ts.ssl.com');
expect(result).toBeInstanceOf(Uint8Array);
expect(result).toEqual(new Uint8Array([10, 20, 30, 40]));
});
it('should propagate errors from PdfSigner.sign()', async () => {
mockSign.mockRejectedValueOnce(new Error('TSA server unreachable'));
await expect(
timestampPdf(samplePdfBytes, 'http://invalid-tsa.example.com')
).rejects.toThrow('TSA server unreachable');
});
it('should work with different TSA URLs', async () => {
const fakeSigned = new Uint8Array([1]);
mockSign.mockResolvedValue(fakeSigned);
const urls = [
'http://timestamp.digicert.com',
'http://timestamp.sectigo.com',
'https://freetsa.org/tsr',
];
for (const url of urls) {
vi.mocked(PdfSigner).mockClear();
await timestampPdf(samplePdfBytes, url);
expect(PdfSigner).toHaveBeenCalledWith({
signdate: { url },
});
}
});
it('should not modify the original PDF bytes', async () => {
const fakeSigned = new Uint8Array([1, 2, 3]);
mockSign.mockResolvedValueOnce(fakeSigned);
const originalCopy = new Uint8Array(samplePdfBytes);
await timestampPdf(samplePdfBytes, 'http://timestamp.digicert.com');
expect(samplePdfBytes).toEqual(originalCopy);
});
});

BIN
src/tests/fixtures/sample.pdf vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,154 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { TIMESTAMP_TSA_PRESETS } from '@/js/config/timestamp-tsa';
// Mock external dependencies before importing the node
vi.mock('rete', () => ({
ClassicPreset: {
Node: class {
addInput() {}
addOutput() {}
addControl() {}
controls: Record<string, unknown> = {};
},
Input: class {
constructor(
public socket: unknown,
public label: string
) {}
},
Output: class {
constructor(
public socket: unknown,
public label: string
) {}
},
InputControl: class {
value: string;
constructor(
public type: string,
public options: { initial: string }
) {
this.value = options.initial;
}
},
},
}));
vi.mock('@/js/workflow/sockets', () => ({
pdfSocket: {},
}));
vi.mock('@/js/workflow/nodes/base-node', () => ({
BaseWorkflowNode: class {
addInput() {}
addOutput() {}
addControl() {}
controls: Record<string, unknown> = {};
},
}));
vi.mock('pdf-lib', () => ({
PDFDocument: {
load: vi.fn().mockResolvedValue({}),
},
}));
vi.mock('@/js/logic/digital-sign-pdf', () => ({
timestampPdf: vi.fn().mockResolvedValue(new Uint8Array([1, 2, 3])),
}));
vi.mock('@/js/workflow/types', () => ({
requirePdfInput: vi.fn((inputs: Record<string, unknown[]>) => inputs['pdf']),
processBatch: vi.fn(
async (
inputs: Array<{ bytes: Uint8Array; filename: string }>,
fn: (input: { bytes: Uint8Array; filename: string }) => Promise<unknown>
) => {
const results = [];
for (const input of inputs) {
results.push(await fn(input));
}
return results;
}
),
}));
import { TimestampNode } from '@/js/workflow/nodes/timestamp-node';
import { timestampPdf } from '@/js/logic/digital-sign-pdf';
describe('TimestampNode', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should be instantiable', () => {
const node = new TimestampNode();
expect(node).toBeDefined();
});
it('should have the correct category', () => {
const node = new TimestampNode();
expect(node.category).toBe('Secure PDF');
});
it('should have the correct icon', () => {
const node = new TimestampNode();
expect(node.icon).toBe('ph-clock');
});
it('should have a description', () => {
const node = new TimestampNode();
expect(node.description).toBe('Add an RFC 3161 document timestamp');
});
it('should return TSA presets', () => {
const node = new TimestampNode();
const presets = node.getTsaPresets();
expect(presets).toBe(TIMESTAMP_TSA_PRESETS);
expect(presets.length).toBeGreaterThan(0);
});
it('should use the first TSA preset as default URL', () => {
const node = new TimestampNode();
const presets = node.getTsaPresets();
expect(presets[0].url).toBe(TIMESTAMP_TSA_PRESETS[0].url);
});
it('should call timestampPdf with correct TSA URL via data()', async () => {
const node = new TimestampNode();
const mockInput = [
{ bytes: new Uint8Array([1, 2, 3]), filename: 'test.pdf' },
];
await node.data({ pdf: mockInput });
expect(timestampPdf).toHaveBeenCalledWith(
mockInput[0].bytes,
TIMESTAMP_TSA_PRESETS[0].url
);
});
it('should generate _timestamped suffix in output filename via data()', async () => {
const node = new TimestampNode();
const mockInput = [
{ bytes: new Uint8Array([1, 2, 3]), filename: 'report.pdf' },
];
const result = (await node.data({ pdf: mockInput })) as {
pdf: Array<{ filename: string }>;
};
expect(result.pdf[0].filename).toBe('report_timestamped.pdf');
});
it('should wrap errors from timestampPdf with TSA context', async () => {
vi.mocked(timestampPdf).mockRejectedValueOnce(new Error('Network error'));
const node = new TimestampNode();
await expect(
node.data({
pdf: [{ bytes: new Uint8Array([1]), filename: 'test.pdf' }],
})
).rejects.toThrow(/Failed to timestamp using TSA/);
});
});

View File

@@ -0,0 +1,184 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { TIMESTAMP_TSA_PRESETS } from '@/js/config/timestamp-tsa';
/**
* Tests for the Timestamp PDF page logic.
*
* These tests validate DOM interactions, state management, and UI behavior
* of the timestamp-pdf-page module without calling real TSA servers.
*/
function buildPageHtml(): string {
return `
<input type="file" id="file-input" accept=".pdf" />
<div id="drop-zone"></div>
<div id="file-display-area"></div>
<div id="tsa-section" class="hidden">
<select id="tsa-preset"></select>
</div>
<button id="process-btn" class="hidden" disabled></button>
<button id="back-to-tools"></button>
`;
}
describe('Timestamp PDF Page', () => {
beforeEach(() => {
document.body.innerHTML = buildPageHtml();
});
describe('TSA Preset Population', () => {
it('should populate the TSA preset select element with all presets', () => {
const select = document.getElementById('tsa-preset') as HTMLSelectElement;
for (const preset of TIMESTAMP_TSA_PRESETS) {
const option = document.createElement('option');
option.value = preset.url;
option.textContent = preset.label;
select.append(option);
}
expect(select.options.length).toBe(TIMESTAMP_TSA_PRESETS.length);
});
it('should set option values to TSA URLs', () => {
const select = document.getElementById('tsa-preset') as HTMLSelectElement;
for (const preset of TIMESTAMP_TSA_PRESETS) {
const option = document.createElement('option');
option.value = preset.url;
option.textContent = preset.label;
select.append(option);
}
for (let i = 0; i < TIMESTAMP_TSA_PRESETS.length; i++) {
expect(select.options[i].value).toBe(TIMESTAMP_TSA_PRESETS[i].url);
expect(select.options[i].textContent).toBe(
TIMESTAMP_TSA_PRESETS[i].label
);
}
});
});
describe('File Validation', () => {
it('should reject non-PDF files based on type', () => {
const file = new File(['content'], 'image.png', { type: 'image/png' });
const isValidPdf =
file.type === 'application/pdf' ||
file.name.toLowerCase().endsWith('.pdf');
expect(isValidPdf).toBe(false);
});
it('should accept files with application/pdf type', () => {
const file = new File(['content'], 'document.pdf', {
type: 'application/pdf',
});
const isValidPdf =
file.type === 'application/pdf' ||
file.name.toLowerCase().endsWith('.pdf');
expect(isValidPdf).toBe(true);
});
it('should accept files with .pdf extension regardless of MIME type', () => {
const file = new File(['content'], 'document.pdf', {
type: 'application/octet-stream',
});
const isValidPdf =
file.type === 'application/pdf' ||
file.name.toLowerCase().endsWith('.pdf');
expect(isValidPdf).toBe(true);
});
it('should handle case-insensitive PDF extension', () => {
const file = new File(['content'], 'document.PDF', {
type: 'application/octet-stream',
});
const isValidPdf =
file.type === 'application/pdf' ||
file.name.toLowerCase().endsWith('.pdf');
expect(isValidPdf).toBe(true);
});
});
describe('Output Filename', () => {
it('should append _timestamped before .pdf extension', () => {
const inputName = 'document.pdf';
const outputName = inputName.replace(/\.pdf$/i, '_timestamped.pdf');
expect(outputName).toBe('document_timestamped.pdf');
});
it('should handle uppercase .PDF extension', () => {
const inputName = 'document.PDF';
const outputName = inputName.replace(/\.pdf$/i, '_timestamped.pdf');
expect(outputName).toBe('document_timestamped.pdf');
});
it('should handle filenames with multiple dots', () => {
const inputName = 'my.report.2024.pdf';
const outputName = inputName.replace(/\.pdf$/i, '_timestamped.pdf');
expect(outputName).toBe('my.report.2024_timestamped.pdf');
});
});
describe('UI State Management', () => {
it('should have TSA section hidden initially', () => {
const tsaSection = document.getElementById('tsa-section');
expect(tsaSection?.classList.contains('hidden')).toBe(true);
});
it('should have process button hidden initially', () => {
const processBtn = document.getElementById('process-btn');
expect(processBtn?.classList.contains('hidden')).toBe(true);
});
it('should have process button disabled initially', () => {
const processBtn = document.getElementById(
'process-btn'
) as HTMLButtonElement;
expect(processBtn.disabled).toBe(true);
});
it('should show TSA section when hidden class is removed', () => {
const tsaSection = document.getElementById('tsa-section')!;
tsaSection.classList.remove('hidden');
expect(tsaSection.classList.contains('hidden')).toBe(false);
});
it('should enable process button when PDF is loaded', () => {
const processBtn = document.getElementById(
'process-btn'
) as HTMLButtonElement;
processBtn.classList.remove('hidden');
processBtn.disabled = false;
expect(processBtn.disabled).toBe(false);
expect(processBtn.classList.contains('hidden')).toBe(false);
});
});
describe('Drop Zone', () => {
it('should exist in the DOM', () => {
const dropZone = document.getElementById('drop-zone');
expect(dropZone).not.toBeNull();
});
it('should add highlight class on dragover', () => {
const dropZone = document.getElementById('drop-zone')!;
dropZone.classList.add('bg-gray-700');
expect(dropZone.classList.contains('bg-gray-700')).toBe(true);
});
it('should remove highlight class on dragleave', () => {
const dropZone = document.getElementById('drop-zone')!;
dropZone.classList.add('bg-gray-700');
dropZone.classList.remove('bg-gray-700');
expect(dropZone.classList.contains('bg-gray-700')).toBe(false);
});
});
});

View File

@@ -0,0 +1,49 @@
import { describe, it, expect } from 'vitest';
import {
TIMESTAMP_TSA_PRESETS,
type TimestampTsaPreset,
} from '@/js/config/timestamp-tsa';
describe('Timestamp TSA Presets', () => {
it('should be a non-empty array', () => {
expect(Array.isArray(TIMESTAMP_TSA_PRESETS)).toBe(true);
expect(TIMESTAMP_TSA_PRESETS.length).toBeGreaterThan(0);
});
it('should contain only objects with label and url strings', () => {
for (const preset of TIMESTAMP_TSA_PRESETS) {
expect(typeof preset.label).toBe('string');
expect(preset.label.length).toBeGreaterThan(0);
expect(typeof preset.url).toBe('string');
expect(preset.url.length).toBeGreaterThan(0);
}
});
it('should have unique labels', () => {
const labels = TIMESTAMP_TSA_PRESETS.map((p) => p.label);
expect(new Set(labels).size).toBe(labels.length);
});
it('should have unique URLs', () => {
const urls = TIMESTAMP_TSA_PRESETS.map((p) => p.url);
expect(new Set(urls).size).toBe(urls.length);
});
it('should have valid URL formats', () => {
for (const preset of TIMESTAMP_TSA_PRESETS) {
expect(() => new URL(preset.url)).not.toThrow();
}
});
it('should include well-known TSA providers', () => {
const labels = TIMESTAMP_TSA_PRESETS.map((p) => p.label);
expect(labels).toContain('DigiCert');
expect(labels).toContain('Sectigo');
});
it('should satisfy the TimestampTsaPreset interface', () => {
const preset: TimestampTsaPreset = TIMESTAMP_TSA_PRESETS[0];
expect(preset).toHaveProperty('label');
expect(preset).toHaveProperty('url');
});
});