mirror of
https://github.com/alam00000/bentopdf.git
synced 2026-11-12 09:34:01 +01:00
Merge pull request #956 from AB527/fix/952-validate-doc-timestamps
(Bug) Detect and verify document timestamps in Validate Signature
This commit is contained in:
6 files changed
+422
-38
No files matched your search
@@ -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(
|
||||
<div class="flex items-center gap-3">
|
||||
<i data-lucide="${statusIcon}" class="w-6 h-6 ${statusColor}"></i>
|
||||
<div>
|
||||
<h3 class="font-semibold text-white">${trHtml('Signature {{n}}', { n: index + 1 })}</h3>
|
||||
<h3 class="font-semibold text-white">${isTimestamp ? trHtml('Document Timestamp {{n}}', { n: index + 1 }) : trHtml('Signature {{n}}', { n: index + 1 })}</h3>
|
||||
<p class="text-sm ${statusColor}">${statusText}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -448,7 +453,7 @@ function createSignatureCard(
|
||||
<div class="space-y-3 text-sm">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p class="text-gray-400">${trHtml('Signed By')}</p>
|
||||
<p class="text-gray-400">${isTimestamp ? trHtml('Timestamped By') : trHtml('Signed By')}</p>
|
||||
<p class="text-white font-medium">${escapeHtml(result.signerName)}</p>
|
||||
${result.signerOrg ? `<p class="text-gray-400 text-xs">${escapeHtml(result.signerOrg)}</p>` : ''}
|
||||
${result.signerEmail ? `<p class="text-gray-400 text-xs">${escapeHtml(result.signerEmail)}</p>` : ''}
|
||||
@@ -464,7 +469,7 @@ function createSignatureCard(
|
||||
result.signatureDate
|
||||
? `
|
||||
<div>
|
||||
<p class="text-gray-400">${trHtml('Signed On')}</p>
|
||||
<p class="text-gray-400">${isTimestamp ? trHtml('Timestamped On') : trHtml('Signed On')}</p>
|
||||
<p class="text-white">${formatDate(result.signatureDate)}</p>
|
||||
</div>
|
||||
`
|
||||
@@ -512,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>
|
||||
|
||||
@@ -11,6 +11,8 @@ const INSECURE_DIGEST_OIDS = new Set<string>([
|
||||
|
||||
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<string, string> = {
|
||||
@@ -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<string, PdfValueSpan>
|
||||
): 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<string, PdfValueSpan>,
|
||||
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<CryptoVerificationResult> {
|
||||
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)) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user