mirror of
https://github.com/alam00000/bentopdf.git
synced 2026-11-12 09:34:01 +01:00
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:
1 parent
2daa7e2ad0
commit
c244fbe8aa
6 files changed
+70
-20
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user