fix(validate-signature): flag weak timestamp imprints and ignore signer entries

Treat an MD5 or SHA-1 messageImprint as an insecure digest and show the
imprint algorithm in Technical Details.

Ignore Reason, Location, ContactInfo and Name on document timestamps, as
ISO 32000-2 Table 255 requires for ETSI.RFC3161.

The insecure digest status could not be reached because such results are
never valid, so these cards read "Invalid Signature". Show it in the
invalid branch instead.
This commit is contained in:
Atharva Bedekar committed 2026-10-31 15:21:54 +05:30
1 parent 2daa7e2ad0
commit c244fbe8aa
6 files changed
+70 -20

No files matched your search

+4 -5
View File
@@ -392,12 +392,10 @@ function createSignatureCard(
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';
@@ -519,6 +517,7 @@ function createSignatureCard(
<p><span class="text-gray-400">${trHtml('Serial Number:')}</span> <span class="text-gray-300 font-mono">${escapeHtml(result.serialNumber)}</span></p>
<p><span class="text-gray-400">${trHtml('Digest Algorithm:')}</span> <span class="text-gray-300">${escapeHtml(result.algorithms.digest)}</span></p>
<p><span class="text-gray-400">${trHtml('Signature Algorithm:')}</span> <span class="text-gray-300">${escapeHtml(result.algorithms.signature)}</span></p>
${result.algorithms.imprint ? `<p><span class="text-gray-400">${trHtml('Timestamp Imprint Algorithm:')}</span> <span class="text-gray-300">${escapeHtml(result.algorithms.imprint)}</span></p>` : ''}
${result.errorMessage ? `<p class="text-red-400">${trHtml('Error: {{error}}', { error: result.errorMessage })}</p>` : ''}
${result.unsupportedAlgorithmReason ? `<p class="text-yellow-300">${escapeHtml(result.unsupportedAlgorithmReason)}</p>` : ''}
</div>
+14 -6
View File
@@ -103,6 +103,9 @@ function readSignatureDictionary(
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})/
);
@@ -110,10 +113,10 @@ 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,
@@ -442,11 +445,17 @@ 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 (timestamp?.genTime) {
@@ -666,8 +675,7 @@ interface TimestampToken {
/**
* 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.
* that wraps plain data.
*/
function unwrapTimestampToken(
contentInfo: forge.asn1.Asn1
+1
View File
@@ -17,6 +17,7 @@ export interface SignatureValidationResult {
algorithms: {
digest: string;
signature: string;
imprint?: string;
};
serialNumber: string;
reason?: string;
@@ -188,6 +188,22 @@ describe('validatePdfSignatures extraction and coverage', () => {
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' },
+29 -9
View File
@@ -26,17 +26,14 @@ 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 {
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(forge.pki.oids.sha256),
node(asn1.Type.NULL, ''),
]),
node(asn1.Type.SEQUENCE, [oid(imprintOid), node(asn1.Type.NULL, '')]),
node(asn1.Type.OCTETSTRING, imprint),
]),
node(asn1.Type.INTEGER, asn1.integerToDer(42).getBytes()),
@@ -47,9 +44,9 @@ function tstInfoDer(imprint: string): string {
}
/** An RFC 3161 token whose TSTInfo carries the given document hash. */
function timestampTokenHex(imprint: string): string {
function timestampTokenHex(imprint: string, imprintOid: string): string {
const p7 = forge.pkcs7.createSignedData();
p7.content = forge.util.createBuffer(tstInfoDer(imprint));
p7.content = forge.util.createBuffer(tstInfoDer(imprint, imprintOid));
p7.addCertificate(tsaCert);
p7.addSigner({
key: keys.privateKey,
@@ -78,7 +75,8 @@ function sha256(content: string): string {
}
function buildTimestampedPdf(
imprintFor: (signedContent: string) => string = sha256
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;
@@ -87,7 +85,7 @@ function buildTimestampedPdf(
RANGE_PLACEHOLDER,
`/ByteRange [${byteRange.join(' ')}]`.padEnd(RANGE_WIDTH)
);
const hex = timestampTokenHex(imprintFor(head + TAIL)).padEnd(
const hex = timestampTokenHex(imprintFor(head + TAIL), imprintOid).padEnd(
HEX_LENGTH,
'0'
);
@@ -102,12 +100,34 @@ describe('document timestamps (RFC 3161)', () => {
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
+6
View File
@@ -9001,6 +9001,12 @@
],
"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"