diff --git a/src/js/logic/validate-signature-pdf-page.ts b/src/js/logic/validate-signature-pdf-page.ts index 5f4956a0..bf9c16cf 100644 --- a/src/js/logic/validate-signature-pdf-page.ts +++ b/src/js/logic/validate-signature-pdf-page.ts @@ -367,7 +367,10 @@ function createSignatureCard( let statusColor = 'text-green-400'; let statusIcon = 'check-circle'; - let statusText = trText('Valid Signature'); + const isTimestamp = result.isTimestamp === true; + let statusText = isTimestamp + ? trText('Valid Timestamp') + : trText('Valid Signature'); if (!result.isValid) { if (result.cryptoVerificationStatus === 'unsupported') { @@ -380,19 +383,19 @@ function createSignatureCard( ) { statusColor = 'text-yellow-400'; statusIcon = 'alert-triangle'; - statusText = trText('Modified After Signing (signed part intact)'); + statusText = isTimestamp + ? trText('Modified After Timestamping (timestamped part intact)') + : trText('Modified After Signing (signed part intact)'); } else { statusColor = 'text-red-400'; statusIcon = 'x-circle'; statusText = result.cryptoVerified === false ? trText('Invalid: Cryptographic Verification Failed') - : trText('Invalid Signature'); + : result.usesInsecureDigest + ? trText('Insecure Digest (MD5 / SHA-1)') + : trText('Invalid Signature'); } - } else if (result.usesInsecureDigest) { - statusColor = 'text-red-400'; - statusIcon = 'x-circle'; - statusText = trText('Insecure Digest (MD5 / SHA-1)'); } else if (result.isExpired) { statusColor = 'text-yellow-400'; statusIcon = 'alert-triangle'; @@ -400,9 +403,11 @@ function createSignatureCard( } else if (!result.isTrusted) { statusColor = 'text-yellow-400'; statusIcon = 'alert-triangle'; - statusText = result.isSelfSigned - ? trText('Self-Signed: Signer Identity Not Verified') - : trText('Signature Intact: Signer Identity Not Verified'); + statusText = isTimestamp + ? trText('Timestamp Intact: Timestamp Authority Not Verified') + : result.isSelfSigned + ? trText('Self-Signed: Signer Identity Not Verified') + : trText('Signature Intact: Signer Identity Not Verified'); } const formatDate = (date: Date) => { @@ -430,7 +435,7 @@ function createSignatureCard(
-

${trHtml('Signature {{n}}', { n: index + 1 })}

+

${isTimestamp ? trHtml('Document Timestamp {{n}}', { n: index + 1 }) : trHtml('Signature {{n}}', { n: index + 1 })}

${statusText}

@@ -448,7 +453,7 @@ function createSignatureCard(
-

${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 +469,7 @@ function createSignatureCard( result.signatureDate ? `
-

${trHtml('Signed On')}

+

${isTimestamp ? trHtml('Timestamped On') : trHtml('Signed On')}

${formatDate(result.signatureDate)}

` @@ -512,6 +517,7 @@ function createSignatureCard(

${trHtml('Serial Number:')} ${escapeHtml(result.serialNumber)}

${trHtml('Digest Algorithm:')} ${escapeHtml(result.algorithms.digest)}

${trHtml('Signature Algorithm:')} ${escapeHtml(result.algorithms.signature)}

+ ${result.algorithms.imprint ? `

${trHtml('Timestamp Imprint Algorithm:')} ${escapeHtml(result.algorithms.imprint)}

` : ''} ${result.errorMessage ? `

${trHtml('Error: {{error}}', { error: result.errorMessage })}

` : ''} ${result.unsupportedAlgorithmReason ? `

${escapeHtml(result.unsupportedAlgorithmReason)}

` : ''}
diff --git a/src/js/logic/validate-signature-pdf.ts b/src/js/logic/validate-signature-pdf.ts index f229ef73..e993947e 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,33 +76,36 @@ 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)); + // A PDF processor should ignore these on ETSI.RFC3161 dictionaries (ISO + // 32000-2, Table 255) + const signerText = (key: string) => (isTimestamp ? undefined : text(key)); const timeMatch = text('M')?.match( /^D:(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/ ); @@ -106,13 +113,14 @@ function readSignatureDictionary( index, contents: new Uint8Array(0), byteRange: [], - reason: text('Reason'), - location: text('Location'), - contactInfo: text('ContactInfo'), - name: text('Name'), + reason: signerText('Reason'), + location: signerText('Location'), + contactInfo: signerText('ContactInfo'), + name: signerText('Name'), signingTime: timeMatch ? `${timeMatch[1]}-${timeMatch[2]}-${timeMatch[3]}T${timeMatch[4]}:${timeMatch[5]}:${timeMatch[6]}` : undefined, + isTimestamp, }; const rangeSpan = entries.get('ByteRange'); @@ -342,6 +350,7 @@ export async function validateSignature( reason: signature.reason, location: signature.location, contactInfo: signature.contactInfo, + isTimestamp: signature.isTimestamp, }; if (signature.extractionError) { @@ -357,6 +366,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 & { @@ -429,14 +445,22 @@ export async function validateSignature( (digestOid && getDigestAlgorithmName(digestOid)) || getDigestAlgorithmName(signerCert.siginfo?.algorithmOid || ''), signature: getSignatureAlgorithmName(signerCert.signatureOid || ''), + imprint: timestamp + ? getDigestAlgorithmName(timestamp.imprintOid) + : undefined, }; if (digestOid && INSECURE_DIGEST_OIDS.has(digestOid)) { result.usesInsecureDigest = true; } + if (timestamp && INSECURE_DIGEST_OIDS.has(timestamp.imprintOid)) { + result.usesInsecureDigest = true; + } // 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 +514,8 @@ export async function validateSignature( pdfBytes, signature.byteRange, signerCert, - signerInfoFields + signerInfoFields, + timestamp ); result.cryptoVerified = verification.status === 'verified'; @@ -640,6 +665,80 @@ 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. + */ +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 +1118,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 +1144,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 +1233,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..fc6de2ac 100644 --- a/src/js/types/validate-signature-type.ts +++ b/src/js/types/validate-signature-type.ts @@ -17,6 +17,7 @@ export interface SignatureValidationResult { algorithms: { digest: string; signature: string; + imprint?: string; }; serialNumber: string; reason?: string; @@ -29,6 +30,7 @@ export interface SignatureValidationResult { cryptoVerificationStatus?: 'verified' | 'failed' | 'unsupported'; unsupportedAlgorithmReason?: string; usesInsecureDigest?: boolean; + isTimestamp?: boolean; } export interface ExtractedSignature { @@ -40,6 +42,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..ba15b632 100644 --- a/src/tests/validate-signature-extraction.test.ts +++ b/src/tests/validate-signature-extraction.test.ts @@ -167,15 +167,43 @@ 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('ignores Reason, Location, ContactInfo and Name on document timestamps', () => { + const pdfBytes = new TextEncoder().encode( + '%PDF-1.7\n' + + '1 0 obj\n<< /Type /DocTimeStamp /SubFilter /ETSI.RFC3161 ' + + '/Reason (r) /Location (l) /ContactInfo (c) /Name (n) ' + + '/ByteRange [0 10 20 10] /Contents <00> >>\nendobj\n%%EOF\n' + ); + + const [signature] = extractSignatures(pdfBytes); + expect(signature.isTimestamp).toBe(true); + expect(signature.reason).toBeUndefined(); + expect(signature.location).toBeUndefined(); + expect(signature.contactInfo).toBeUndefined(); + expect(signature.name).toBeUndefined(); + }); + 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..5a2e4fad --- /dev/null +++ b/src/tests/validate-signature-timestamp.test.ts @@ -0,0 +1,167 @@ +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, imprintOid: 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(imprintOid), 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, imprintOid: string): string { + const p7 = forge.pkcs7.createSignedData(); + p7.content = forge.util.createBuffer(tstInfoDer(imprint, imprintOid)); + 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, + imprintOid: string = forge.pki.oids.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), imprintOid).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.usesInsecureDigest).toBeFalsy(); + expect(result.algorithms.imprint).toBe('SHA-256'); + expect(result.signerName).toBe('Test TSA'); + expect(result.signatureDate?.toISOString()).toBe( + '2026-01-02T03:04:05.000Z' + ); + }); + + it.each([ + ['SHA-1', forge.md.sha1, forge.pki.oids.sha1], + ['MD5', forge.md.md5, forge.pki.oids.md5], + ])('treats a %s messageImprint as insecure', async (name, algorithm, id) => { + const imprintFor = (content: string) => { + const md = algorithm.create(); + md.update(content); + return md.digest().bytes(); + }; + + const [result] = await validatePdfSignatures( + buildTimestampedPdf(imprintFor, id) + ); + + expect(result.cryptoVerificationStatus).toBe('verified'); + expect(result.usesInsecureDigest).toBe(true); + expect(result.isValid).toBe(false); + expect(result.algorithms.imprint).toBe(name); + }); + + 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 0044f85c..4e057e74 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" @@ -5222,6 +5240,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" @@ -8903,6 +8927,30 @@ ], "fileCount": 1 }, + "Timestamp Imprint Algorithm:": { + "files": [ + "logic/validate-signature-pdf-page.ts" + ], + "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" @@ -9440,6 +9488,12 @@ ], "fileCount": 1 }, + "Valid Timestamp": { + "files": [ + "logic/validate-signature-pdf-page.ts" + ], + "fileCount": 1 + }, "Valid Until": { "files": [ "logic/validate-signature-pdf-page.ts"