-
${trHtml('Signed By')}
+
${isTimestamp ? trHtml('Timestamped By') : trHtml('Signed By')}
${escapeHtml(result.signerName)}
${result.signerOrg ? `
${escapeHtml(result.signerOrg)}
` : ''}
${result.signerEmail ? `
${escapeHtml(result.signerEmail)}
` : ''}
@@ -464,7 +471,7 @@ function createSignatureCard(
result.signatureDate
? `
-
${trHtml('Signed On')}
+
${isTimestamp ? trHtml('Timestamped On') : trHtml('Signed On')}
${formatDate(result.signatureDate)}
`
diff --git a/src/js/logic/validate-signature-pdf.ts b/src/js/logic/validate-signature-pdf.ts
index f229ef73..3ccb9384 100644
--- a/src/js/logic/validate-signature-pdf.ts
+++ b/src/js/logic/validate-signature-pdf.ts
@@ -11,6 +11,8 @@ const INSECURE_DIGEST_OIDS = new Set
([
const MIN_RSA_EXPONENT = new forge.jsbn.BigInteger('65537');
+const TST_INFO_OID = '1.2.840.113549.1.9.16.1.4';
+
const PDF_WHITESPACE = new Set('\0\t\n\f\r ');
const PDF_TOKEN_TERMINATORS = new Set('\0\t\n\f\r ()<>[]{}/%');
const PDF_STRING_ESCAPES: Record = {
@@ -52,13 +54,15 @@ export function extractSignatures(
if (end < 0) continue;
for (const entries of dictionaries) {
- if (isSignatureDictionary(pdfString, entries)) {
+ const kind = getSignatureKind(pdfString, entries);
+ if (kind) {
signatures.push(
readSignatureDictionary(
pdfString,
pdfBytes,
entries,
- signatures.length
+ signatures.length,
+ kind === 'timestamp'
)
);
}
@@ -72,31 +76,31 @@ export function extractSignatures(
* /Type is optional in a signature dictionary (PDF 32000-1, 12.8.1, Table
* 252), and some signers leave it out, so a dictionary without /Type that
* carries both /ByteRange and /Contents is a signature too. Document
- * timestamps (/Type /DocTimeStamp, /SubFilter /ETSI.RFC3161) hold an RFC 3161
- * token rather than a CMS signature over the document and stay out.
+ * timestamps (/Type /DocTimeStamp, /SubFilter /ETSI.RFC3161, ISO 32000-2,
+ * 12.8.5) hold an RFC 3161 token instead of a CMS signature over the
+ * document, so they are reported separately and verified differently.
*/
-function isSignatureDictionary(
+function getSignatureKind(
pdfString: string,
entries: Map
-): boolean {
+): 'signature' | 'timestamp' | null {
const name = (key: string) => {
const span = entries.get(key);
return span ? pdfString.slice(span.start, span.end) : undefined;
};
const type = name('Type');
- if (type !== undefined) return type === '/Sig';
- return (
- entries.has('ByteRange') &&
- entries.has('Contents') &&
- name('SubFilter') !== '/ETSI.RFC3161'
- );
+ if (type === '/DocTimeStamp') return 'timestamp';
+ if (type !== undefined) return type === '/Sig' ? 'signature' : null;
+ if (!entries.has('ByteRange') || !entries.has('Contents')) return null;
+ return name('SubFilter') === '/ETSI.RFC3161' ? 'timestamp' : 'signature';
}
function readSignatureDictionary(
pdfString: string,
pdfBytes: Uint8Array,
entries: Map,
- index: number
+ index: number,
+ isTimestamp: boolean
): ExtractedSignatureWithError {
const text = (key: string) => decodePdfText(pdfBytes, entries.get(key));
const timeMatch = text('M')?.match(
@@ -113,6 +117,7 @@ function readSignatureDictionary(
signingTime: timeMatch
? `${timeMatch[1]}-${timeMatch[2]}-${timeMatch[3]}T${timeMatch[4]}:${timeMatch[5]}:${timeMatch[6]}`
: undefined,
+ isTimestamp,
};
const rangeSpan = entries.get('ByteRange');
@@ -342,6 +347,7 @@ export async function validateSignature(
reason: signature.reason,
location: signature.location,
contactInfo: signature.contactInfo,
+ isTimestamp: signature.isTimestamp,
};
if (signature.extractionError) {
@@ -357,6 +363,13 @@ export async function validateSignature(
Array.from(signature.contents)
);
const asn1 = forge.asn1.fromDer(binaryString);
+ const timestamp = signature.isTimestamp ? unwrapTimestampToken(asn1) : null;
+ if (signature.isTimestamp && !timestamp) {
+ result.errorMessage = trText('Could not read the timestamp token');
+ result.cryptoVerified = false;
+ result.cryptoVerificationStatus = 'failed';
+ return result;
+ }
const p7 = forge.pkcs7.messageFromAsn1(
asn1
) as forge.pkcs7.PkcsSignedData & {
@@ -436,7 +449,9 @@ export async function validateSignature(
}
// Parse signing time if available in signature
- if (signature.signingTime) {
+ if (timestamp?.genTime) {
+ result.signatureDate = timestamp.genTime;
+ } else if (signature.signingTime) {
result.signatureDate = new Date(signature.signingTime);
} else {
try {
@@ -490,7 +505,8 @@ export async function validateSignature(
pdfBytes,
signature.byteRange,
signerCert,
- signerInfoFields
+ signerInfoFields,
+ timestamp
);
result.cryptoVerified = verification.status === 'verified';
@@ -640,6 +656,81 @@ interface SignerInfoFields {
signatureBytes: string;
}
+interface TimestampToken {
+ tstInfo: Uint8Array;
+ imprintOid: string;
+ imprint: string;
+ genTime?: Date;
+}
+
+/**
+ * Reads the TSTInfo out of an RFC 3161 timestamp token and relabels its
+ * encapsulated content as id-data, because node-forge only parses SignedData
+ * that wraps plain data. Only the label changes: the signature covers the
+ * signed attributes, which keep the real content type.
+ */
+function unwrapTimestampToken(
+ contentInfo: forge.asn1.Asn1
+): TimestampToken | null {
+ try {
+ const signedData = asn1Children(asn1Children(contentInfo)?.[1])?.[0];
+ const encapContentInfo = asn1Children(signedData)?.[2];
+ const [typeNode, explicitContent] = asn1Children(encapContentInfo) ?? [];
+ if (
+ typeNode?.type !== forge.asn1.Type.OID ||
+ forge.asn1.derToOid(typeNode.value as string) !== TST_INFO_OID
+ ) {
+ return null;
+ }
+
+ const octets = asn1Children(explicitContent)?.[0];
+ if (!octets || octets.type !== forge.asn1.Type.OCTETSTRING) return null;
+ const tstInfoDer =
+ typeof octets.value === 'string'
+ ? octets.value
+ : (octets.value as forge.asn1.Asn1[])
+ .map((chunk) => chunk.value as string)
+ .join('');
+
+ const tstInfo = asn1Children(forge.asn1.fromDer(tstInfoDer));
+ const [hashAlgorithm, hashedMessage] = asn1Children(tstInfo?.[2]) ?? [];
+ const imprintOidNode = asn1Children(hashAlgorithm)?.[0];
+ if (
+ imprintOidNode?.type !== forge.asn1.Type.OID ||
+ hashedMessage?.type !== forge.asn1.Type.OCTETSTRING ||
+ typeof hashedMessage.value !== 'string'
+ ) {
+ return null;
+ }
+
+ let genTime: Date | undefined;
+ const genTimeNode = tstInfo?.[4];
+ if (
+ genTimeNode?.type === forge.asn1.Type.GENERALIZEDTIME &&
+ typeof genTimeNode.value === 'string'
+ ) {
+ genTime = forge.asn1.generalizedTimeToDate(genTimeNode.value);
+ }
+
+ typeNode.value = forge.asn1.oidToDer(forge.pki.oids.data).getBytes();
+
+ return {
+ tstInfo: latin1ToUint8(tstInfoDer),
+ imprintOid: forge.asn1.derToOid(imprintOidNode.value as string),
+ imprint: hashedMessage.value,
+ genTime,
+ };
+ } catch {
+ return null;
+ }
+}
+
+function asn1Children(
+ node: forge.asn1.Asn1 | undefined
+): forge.asn1.Asn1[] | null {
+ return node && Array.isArray(node.value) ? node.value : null;
+}
+
function extractSignerInfoFields(
p7: forge.pkcs7.PkcsSignedData & {
rawCapture?: {
@@ -1019,7 +1110,8 @@ async function performCryptoVerification(
pdfBytes: Uint8Array,
byteRange: number[],
signerCert: forge.pki.Certificate,
- fields: SignerInfoFields | null
+ fields: SignerInfoFields | null,
+ timestamp: TimestampToken | null = null
): Promise {
if (!fields) {
return { status: 'failed', reason: trText('Could not parse signer info') };
@@ -1044,7 +1136,33 @@ async function performCryptoVerification(
signedContent.set(pdfBytes.subarray(start1, start1 + len1), 0);
signedContent.set(pdfBytes.subarray(start2, start2 + len2), len1);
- md.update(uint8ToLatin1(signedContent));
+ // A timestamp token signs its TSTInfo, which in turn holds the hash of the
+ // document bytes (RFC 3161, 2.4.2), so the document is checked against that
+ // imprint and the CMS signature against the TSTInfo.
+ let content: Uint8Array = signedContent;
+ if (timestamp) {
+ const imprintMd = createMd(timestamp.imprintOid);
+ if (!imprintMd) {
+ return {
+ status: 'unsupported',
+ reason: trText('Unsupported digest OID {{digestOid}}', {
+ digestOid: timestamp.imprintOid,
+ }),
+ };
+ }
+ imprintMd.update(uint8ToLatin1(signedContent));
+ if (imprintMd.digest().bytes() !== timestamp.imprint) {
+ return {
+ status: 'failed',
+ reason: trText(
+ 'Document hash does not match the timestamp: PDF was modified after timestamping'
+ ),
+ };
+ }
+ content = timestamp.tstInfo;
+ }
+
+ md.update(uint8ToLatin1(content));
const contentHashBytes = md.digest().bytes();
const authAttrs = fields.authAttrs;
@@ -1107,7 +1225,7 @@ async function performCryptoVerification(
);
signedBytesForVerify = latin1ToUint8(forge.asn1.toDer(asSet).getBytes());
} else {
- signedBytesForVerify = signedContent;
+ signedBytesForVerify = content;
}
if (scheme.kind === 'rsa-pkcs1' && !hasLowRsaExponent(signerCert.publicKey)) {
diff --git a/src/js/types/validate-signature-type.ts b/src/js/types/validate-signature-type.ts
index 453f8886..4b2088d8 100644
--- a/src/js/types/validate-signature-type.ts
+++ b/src/js/types/validate-signature-type.ts
@@ -29,6 +29,7 @@ export interface SignatureValidationResult {
cryptoVerificationStatus?: 'verified' | 'failed' | 'unsupported';
unsupportedAlgorithmReason?: string;
usesInsecureDigest?: boolean;
+ isTimestamp?: boolean;
}
export interface ExtractedSignature {
@@ -40,6 +41,7 @@ export interface ExtractedSignature {
contactInfo?: string;
name?: string;
signingTime?: string;
+ isTimestamp?: boolean;
}
export interface ValidateSignatureState {
diff --git a/src/tests/validate-signature-extraction.test.ts b/src/tests/validate-signature-extraction.test.ts
index fac50880..04037947 100644
--- a/src/tests/validate-signature-extraction.test.ts
+++ b/src/tests/validate-signature-extraction.test.ts
@@ -167,15 +167,27 @@ describe('validatePdfSignatures extraction and coverage', () => {
const pdfBytes = new TextEncoder().encode(
'%PDF-1.7\n' +
'1 0 obj\n<< /Subtype /Text /Contents (A note) >>\nendobj\n' +
- '2 0 obj\n<< /Filter /Adobe.PPKLite /SubFilter /ETSI.RFC3161 ' +
- '/ByteRange [0 10 20 10] /Contents <00> >>\nendobj\n' +
- '3 0 obj\n<< /Type /DocTimeStamp /ByteRange [0 10 20 10] ' +
+ '2 0 obj\n<< /Type /Annot /ByteRange [0 10 20 10] ' +
'/Contents <00> >>\nendobj\n%%EOF\n'
);
expect(countSignatures(pdfBytes)).toBe(0);
});
+ it('reports document timestamps as timestamps, typed or not', () => {
+ const pdfBytes = new TextEncoder().encode(
+ '%PDF-1.7\n' +
+ '1 0 obj\n<< /Filter /Adobe.PPKLite /SubFilter /ETSI.RFC3161 ' +
+ '/ByteRange [0 10 20 10] /Contents <00> >>\nendobj\n' +
+ '2 0 obj\n<< /Type /DocTimeStamp /ByteRange [0 10 20 10] ' +
+ '/Contents <00> >>\nendobj\n%%EOF\n'
+ );
+
+ const signatures = extractSignatures(pdfBytes);
+ expect(signatures).toHaveLength(2);
+ expect(signatures.every((sig) => sig.isTimestamp)).toBe(true);
+ });
+
it('reports an intact signature as partial when an incremental update follows', async () => {
const signed = await signPdf(samplePdf, certificateData, {
signatureInfo: { reason: 'Original' },
diff --git a/src/tests/validate-signature-timestamp.test.ts b/src/tests/validate-signature-timestamp.test.ts
new file mode 100644
index 00000000..15860b11
--- /dev/null
+++ b/src/tests/validate-signature-timestamp.test.ts
@@ -0,0 +1,147 @@
+import { describe, it, expect } from 'vitest';
+import forge from 'node-forge';
+import { validatePdfSignatures } from '@/js/logic/validate-signature-pdf';
+
+const TST_INFO_OID = '1.2.840.113549.1.9.16.1.4';
+const RANGE_PLACEHOLDER = '{{RANGE}}';
+const RANGE_WIDTH = 64;
+const HEX_LENGTH = 8192;
+const HEAD =
+ '%PDF-1.7\n1 0 obj\n<< /Type /DocTimeStamp /Filter /Adobe.PPKLite ' +
+ `/SubFilter /ETSI.RFC3161 ${RANGE_PLACEHOLDER} /Contents `;
+const TAIL = ' >>\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF\n';
+
+const keys = forge.pki.rsa.generateKeyPair(1024);
+const tsaCert = forge.pki.createCertificate();
+tsaCert.publicKey = keys.publicKey;
+tsaCert.serialNumber = '02';
+tsaCert.validity.notBefore = new Date(Date.now() - 86400000);
+tsaCert.validity.notAfter = new Date(Date.now() + 86400000 * 365);
+tsaCert.setSubject([{ name: 'commonName', value: 'Test TSA' }]);
+tsaCert.setIssuer([{ name: 'commonName', value: 'Test TSA' }]);
+tsaCert.sign(keys.privateKey, forge.md.sha256.create());
+
+const { asn1 } = forge;
+const node = (type: number, value: string | forge.asn1.Asn1[]) =>
+ asn1.create(asn1.Class.UNIVERSAL, type, Array.isArray(value), value);
+const oid = (id: string) => node(asn1.Type.OID, asn1.oidToDer(id).getBytes());
+
+function tstInfoDer(imprint: string): string {
+ return asn1
+ .toDer(
+ node(asn1.Type.SEQUENCE, [
+ node(asn1.Type.INTEGER, asn1.integerToDer(1).getBytes()),
+ oid('1.2.3.4.1'),
+ node(asn1.Type.SEQUENCE, [
+ node(asn1.Type.SEQUENCE, [
+ oid(forge.pki.oids.sha256),
+ node(asn1.Type.NULL, ''),
+ ]),
+ node(asn1.Type.OCTETSTRING, imprint),
+ ]),
+ node(asn1.Type.INTEGER, asn1.integerToDer(42).getBytes()),
+ node(asn1.Type.GENERALIZEDTIME, '20260102030405Z'),
+ ])
+ )
+ .getBytes();
+}
+
+/** An RFC 3161 token whose TSTInfo carries the given document hash. */
+function timestampTokenHex(imprint: string): string {
+ const p7 = forge.pkcs7.createSignedData();
+ p7.content = forge.util.createBuffer(tstInfoDer(imprint));
+ p7.addCertificate(tsaCert);
+ p7.addSigner({
+ key: keys.privateKey,
+ certificate: tsaCert,
+ digestAlgorithm: forge.pki.oids.sha256,
+ authenticatedAttributes: [
+ { type: forge.pki.oids.contentType, value: TST_INFO_OID },
+ { type: forge.pki.oids.messageDigest },
+ ],
+ });
+ p7.sign();
+ const contentInfo = p7.toAsn1();
+ // node-forge always labels the content as id-data; a TSA labels it TSTInfo
+ const signedData = (contentInfo.value as forge.asn1.Asn1[])[1]
+ .value as forge.asn1.Asn1[];
+ const encap = (signedData[0].value as forge.asn1.Asn1[])[2]
+ .value as forge.asn1.Asn1[];
+ encap[0] = oid(TST_INFO_OID);
+ return forge.util.bytesToHex(asn1.toDer(contentInfo).getBytes());
+}
+
+function sha256(content: string): string {
+ const md = forge.md.sha256.create();
+ md.update(content);
+ return md.digest().bytes();
+}
+
+function buildTimestampedPdf(
+ imprintFor: (signedContent: string) => string = sha256
+): Uint8Array {
+ const headLength = HEAD.length - RANGE_PLACEHOLDER.length + RANGE_WIDTH;
+ const gapLength = HEX_LENGTH + 2;
+ const byteRange = [0, headLength, headLength + gapLength, TAIL.length];
+ const head = HEAD.replace(
+ RANGE_PLACEHOLDER,
+ `/ByteRange [${byteRange.join(' ')}]`.padEnd(RANGE_WIDTH)
+ );
+ const hex = timestampTokenHex(imprintFor(head + TAIL)).padEnd(
+ HEX_LENGTH,
+ '0'
+ );
+ return new TextEncoder().encode(`${head}<${hex}>${TAIL}`);
+}
+
+describe('document timestamps (RFC 3161)', () => {
+ it('verifies an intact document timestamp', async () => {
+ const [result] = await validatePdfSignatures(buildTimestampedPdf());
+
+ expect(result.isTimestamp).toBe(true);
+ expect(result.cryptoVerificationStatus).toBe('verified');
+ expect(result.coverageStatus).toBe('full');
+ expect(result.isValid).toBe(true);
+ expect(result.signerName).toBe('Test TSA');
+ expect(result.signatureDate?.toISOString()).toBe(
+ '2026-01-02T03:04:05.000Z'
+ );
+ });
+
+ it('fails when the document no longer matches the timestamped hash', async () => {
+ const pdfBytes = buildTimestampedPdf();
+ // Rename /Adobe.PPKLite to /Adobe.QPKLite: still a valid PDF, new bytes
+ const at = new TextDecoder('latin1').decode(pdfBytes).indexOf('PPKLite');
+ pdfBytes[at] = 'Q'.charCodeAt(0);
+
+ const [result] = await validatePdfSignatures(pdfBytes);
+
+ expect(result.isValid).toBe(false);
+ expect(result.cryptoVerificationStatus).toBe('failed');
+ expect(result.errorMessage).toMatch(/modified after timestamping/);
+ });
+
+ it('fails when the token holds the hash of a different document', async () => {
+ const [result] = await validatePdfSignatures(
+ buildTimestampedPdf(() => sha256('some other document'))
+ );
+
+ expect(result.isValid).toBe(false);
+ expect(result.cryptoVerificationStatus).toBe('failed');
+ });
+
+ it('fails when the TSTInfo was altered after the TSA signed it', async () => {
+ const pdfBytes = buildTimestampedPdf();
+ const text = new TextDecoder('latin1').decode(pdfBytes);
+ // GeneralizedTime 20260102030405Z, hex-encoded inside /Contents
+ const timeHex = forge.util.bytesToHex('20260102030405Z');
+ const at = text.indexOf(timeHex);
+ expect(at).toBeGreaterThan(0);
+ pdfBytes[at + 1] = pdfBytes[at + 1] === 0x31 ? 0x32 : 0x31;
+
+ const [result] = await validatePdfSignatures(pdfBytes);
+
+ expect(result.isValid).toBe(false);
+ expect(result.cryptoVerificationStatus).toBe('failed');
+ });
+});
diff --git a/translations/ui-source.json b/translations/ui-source.json
index bd5453ba..8d6f5b77 100644
--- a/translations/ui-source.json
+++ b/translations/ui-source.json
@@ -2155,6 +2155,12 @@
],
"fileCount": 1
},
+ "Could not read the timestamp token": {
+ "files": [
+ "logic/validate-signature-pdf.ts"
+ ],
+ "fileCount": 1
+ },
"Could not reduce file size further. Original: {{originalSize}}, New: {{compressedSize}}.": {
"files": [
"logic/compress-pdf-page.ts"
@@ -2580,12 +2586,24 @@
],
"fileCount": 1
},
+ "Document Timestamp {{n}}": {
+ "files": [
+ "logic/validate-signature-pdf-page.ts"
+ ],
+ "fileCount": 1
+ },
"Document Type": {
"files": [
"logic/page-dimensions-page.ts"
],
"fileCount": 1
},
+ "Document hash does not match the timestamp: PDF was modified after timestamping": {
+ "files": [
+ "logic/validate-signature-pdf.ts"
+ ],
+ "fileCount": 1
+ },
"Document properties": {
"files": [
"logic/edit-pdf-text-dock.ts"
@@ -5246,6 +5264,12 @@
],
"fileCount": 1
},
+ "Modified After Timestamping (timestamped part intact)": {
+ "files": [
+ "logic/validate-signature-pdf-page.ts"
+ ],
+ "fileCount": 1
+ },
"More": {
"files": [
"logic/edit-pdf-text-dock.ts"
@@ -8977,6 +9001,24 @@
],
"fileCount": 1
},
+ "Timestamp Intact: Timestamp Authority Not Verified": {
+ "files": [
+ "logic/validate-signature-pdf-page.ts"
+ ],
+ "fileCount": 1
+ },
+ "Timestamped By": {
+ "files": [
+ "logic/validate-signature-pdf-page.ts"
+ ],
+ "fileCount": 1
+ },
+ "Timestamped On": {
+ "files": [
+ "logic/validate-signature-pdf-page.ts"
+ ],
+ "fileCount": 1
+ },
"Tint": {
"files": [
"workflow/nodes/adjust-colors-node.ts"
@@ -9514,6 +9556,12 @@
],
"fileCount": 1
},
+ "Valid Timestamp": {
+ "files": [
+ "logic/validate-signature-pdf-page.ts"
+ ],
+ "fileCount": 1
+ },
"Valid Until": {
"files": [
"logic/validate-signature-pdf-page.ts"