mirror of
https://github.com/alam00000/bentopdf.git
synced 2026-11-12 09:34:01 +01:00
fix(validate-signature): detect and verify document timestamps
PDFs whose only signature is an RFC 3161 document timestamp (/Type /DocTimeStamp, /SubFilter /ETSI.RFC3161) were reported as having no signatures, because timestamp dictionaries were skipped on purpose. node-forge also cannot parse a timestamp token directly, since it only accepts SignedData that wraps plain data. Timestamps are now extracted and flagged as such. The TSTInfo is read from the token, and verification checks that its messageImprint matches the signed byte range and that the TSA signature covers the TSTInfo. The timestamp's genTime is shown as the date, and the result card uses timestamp wording. Fixes #952
This commit is contained in:
1 parent
7abb219565
commit
2daa7e2ad0
6 files changed
+363
-29
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,7 +383,9 @@ 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';
|
||||
@@ -400,9 +405,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 +437,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 +455,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 +471,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>
|
||||
`
|
||||
|
||||
@@ -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,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<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));
|
||||
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<CryptoVerificationResult> {
|
||||
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)) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user