From 621731bda102eed92b83162b4ab541d092b3da2f Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:37:54 +0100 Subject: [PATCH] Validate RFC 3161 document timestamps and expose timestamping (#7095) # Description of Changes Fixes timestamp issue and adds timestamp to the signing/security policiy --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- .../security/ValidateSignatureController.java | 72 ++++++++++++-- .../DocumentTimestampValidationTest.java | 91 ++++++++++++++++++ .../timestamp/doc-timestamped-tampered.pdf | Bin 0 -> 21985 bytes .../resources/timestamp/doc-timestamped.pdf | Bin 0 -> 21985 bytes .../public/locales/en-US/translation.toml | 4 + .../src/core/types/validateSignature.ts | 4 +- .../components/policies/PolicySetupWizard.tsx | 8 ++ .../proprietary/policies/operations.test.ts | 1 + .../src/proprietary/policies/operations.ts | 7 ++ 9 files changed, 179 insertions(+), 8 deletions(-) create mode 100644 app/core/src/test/java/stirling/software/SPDF/controller/api/security/DocumentTimestampValidationTest.java create mode 100644 app/core/src/test/resources/timestamp/doc-timestamped-tampered.pdf create mode 100644 app/core/src/test/resources/timestamp/doc-timestamped.pdf diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/ValidateSignatureController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/ValidateSignatureController.java index ecfc0ad2db..47fce44876 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/ValidateSignatureController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/ValidateSignatureController.java @@ -23,6 +23,9 @@ import org.bouncycastle.cms.CMSSignedData; import org.bouncycastle.cms.SignerInformation; import org.bouncycastle.cms.SignerInformationStore; import org.bouncycastle.cms.jcajce.JcaSimpleSignerInfoVerifierBuilder; +import org.bouncycastle.operator.jcajce.JcaDigestCalculatorProviderBuilder; +import org.bouncycastle.tsp.TimeStampToken; +import org.bouncycastle.tsp.TimeStampTokenInfo; import org.bouncycastle.util.Store; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -54,6 +57,9 @@ public class ValidateSignatureController { private final CustomPDFDocumentFactory pdfDocumentFactory; private final CertificateValidationService certValidationService; + /** PDF sub-filter identifying an RFC 3161 document timestamp (PAdES-LTV). */ + private static final String SUBFILTER_RFC3161 = "ETSI.RFC3161"; + @InitBinder public void initBinder(WebDataBinder binder) { binder.registerCustomEditor( @@ -128,8 +134,35 @@ public class ValidateSignatureController { byte[] signedContent = sig.getSignedContent(file.getInputStream()); byte[] signatureBytes = sig.getContents(file.getInputStream()); - CMSProcessable content = new CMSProcessableByteArray(signedContent); - CMSSignedData signedData = new CMSSignedData(content, signatureBytes); + // An RFC 3161 document timestamp (PAdES-LTV) carries its signed content + // *inside* the CMS - a TSTInfo - rather than being detached over the document. + // Building it as detached digests the ByteRange against an attribute that + // covers the TSTInfo, which can never match. + boolean isDocTimeStamp = SUBFILTER_RFC3161.equals(sig.getSubFilter()); + CMSSignedData signedData; + if (isDocTimeStamp) { + signedData = new CMSSignedData(signatureBytes); + } else { + CMSProcessable content = new CMSProcessableByteArray(signedContent); + signedData = new CMSSignedData(content, signatureBytes); + } + + // What actually binds a timestamp to this document: the TSTInfo's message + // imprint must equal the digest of the signed byte range. Without this check a + // valid timestamp token for some *other* document would verify happily here. + Date timeStampGenTime = null; + if (isDocTimeStamp) { + TimeStampToken token = new TimeStampToken(signedData); + TimeStampTokenInfo info = token.getTimeStampInfo(); + timeStampGenTime = info.getGenTime(); + if (!timestampCoversContent(info, signedContent)) { + result.setValid(false); + result.setErrorMessage( + "Timestamp message imprint does not match the document"); + results.add(result); + continue; + } + } Store certStore = signedData.getCertificates(); SignerInformationStore signerStore = signedData.getSignerInfos(); @@ -162,7 +195,15 @@ public class ValidateSignatureController { CertificateValidationService.ValidationTime validationTimeResult = certValidationService.extractValidationTime(signerInfo); Date validationTime; - if (validationTimeResult == null) { + if (timeStampGenTime != null) { + // The TSA's own asserted time is the authoritative one here, and is + // exactly what makes the signature verifiable after the cert expires. + validationTime = timeStampGenTime; + // Distinct from "timestamp", which CertificateValidationService already + // uses for a signature countersigned by a TSA. Both are RFC 3161, but + // one attests a signature and the other attests the whole document. + result.setValidationTimeSource("document-timestamp"); + } else if (validationTimeResult == null) { validationTime = new Date(); result.setValidationTimeSource("current"); } else { @@ -235,10 +276,13 @@ public class ValidateSignatureController { // Set basic signature info result.setSignerName(sig.getName()); + // A DocTimeStamp has no /M entry; its date is the TSA's genTime. result.setSignatureDate( - sig.getSignDate() != null - ? sig.getSignDate().getTime().toString() - : null); + timeStampGenTime != null + ? timeStampGenTime.toString() + : sig.getSignDate() != null + ? sig.getSignDate().getTime().toString() + : null); result.setReason(sig.getReason()); result.setLocation(sig.getLocation()); @@ -301,4 +345,20 @@ public class ValidateSignatureController { return ResponseEntity.ok(results); } + + /** + * True when the timestamp token was issued over exactly these bytes. + * + *

The digest algorithm is taken from the token rather than assumed, because a TSA chooses it + * - assuming SHA-256 would silently fail against any TSA that uses something else. + */ + private static boolean timestampCoversContent(TimeStampTokenInfo info, byte[] signedContent) + throws Exception { + org.bouncycastle.operator.DigestCalculator digest = + new JcaDigestCalculatorProviderBuilder().build().get(info.getHashAlgorithm()); + try (java.io.OutputStream out = digest.getOutputStream()) { + out.write(signedContent); + } + return java.util.Arrays.equals(digest.getDigest(), info.getMessageImprintDigest()); + } } diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/DocumentTimestampValidationTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/DocumentTimestampValidationTest.java new file mode 100644 index 0000000000..6e8f1676a6 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/DocumentTimestampValidationTest.java @@ -0,0 +1,91 @@ +package stirling.software.SPDF.controller.api.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.io.InputStream; +import java.util.List; + +import org.apache.pdfbox.Loader; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; +import org.springframework.mock.web.MockMultipartFile; + +import stirling.software.SPDF.model.api.security.SignatureValidationRequest; +import stirling.software.SPDF.model.api.security.SignatureValidationResult; +import stirling.software.SPDF.service.CertificateValidationService; +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.service.CustomPDFDocumentFactory; + +/** + * Validation of RFC 3161 document timestamps (PAdES-LTV). + * + *

These fixtures are a real PDF stamped by a real public TSA (freetsa.org). Before this was + * handled explicitly, every such timestamp was reported invalid: a DocTimeStamp's CMS encapsulates + * a TSTInfo rather than being detached over the document, so digesting the byte range compared + * against the wrong thing and always mismatched. That made the timestamp feature look broken to + * anyone who checked their own output with our validator. + */ +class DocumentTimestampValidationTest { + + private ValidateSignatureController controller; + + @BeforeEach + void setUp() throws Exception { + CertificateValidationService certValidationService = + new CertificateValidationService(null, new ApplicationProperties()); + CustomPDFDocumentFactory factory = org.mockito.Mockito.mock(CustomPDFDocumentFactory.class); + // Delegate to the real loader so the signature dictionary is parsed as in production. + when(factory.load(any(InputStream.class))) + .thenAnswer( + invocation -> + Loader.loadPDF( + ((InputStream) invocation.getArgument(0)).readAllBytes())); + controller = new ValidateSignatureController(factory, certValidationService); + } + + @Test + void aGenuineDocumentTimestampValidates() throws Exception { + SignatureValidationResult result = validate("timestamp/doc-timestamped.pdf"); + + assertThat(result.isValid()).isTrue(); + assertThat(result.getErrorMessage()).isNull(); + // The TSA's asserted time is what keeps the signature verifiable once the signing + // certificate expires, so it must be the time we validate against. + // Deliberately not "timestamp" - that value already means "signature countersigned by a + // TSA", which is a different assertion about a different thing. + assertThat(result.getValidationTimeSource()).isEqualTo("document-timestamp"); + assertThat(result.getSignatureDate()).isNotNull(); + assertThat(result.getSubjectDN()).contains("freetsa.org"); + assertThat(result.isCoversEntireDocument()).isTrue(); + } + + @Test + void aTamperedDocumentFailsTheMessageImprintCheck() throws Exception { + // Same file with a single byte flipped inside the signed range. Without the imprint check + // the CMS signature over the TSTInfo would still verify happily - the token is untouched - + // and a modified document would be reported as validly timestamped. + SignatureValidationResult result = validate("timestamp/doc-timestamped-tampered.pdf"); + + assertThat(result.isValid()).isFalse(); + assertThat(result.getErrorMessage()) + .isEqualTo("Timestamp message imprint does not match the document"); + } + + private SignatureValidationResult validate(String resource) throws IOException { + byte[] bytes; + try (InputStream in = new ClassPathResource(resource).getInputStream()) { + bytes = in.readAllBytes(); + } + SignatureValidationRequest request = new SignatureValidationRequest(); + request.setFileInput( + new MockMultipartFile("fileInput", "doc.pdf", "application/pdf", bytes)); + + List results = controller.validateSignature(request).getBody(); + assertThat(results).hasSize(1); + return results.get(0); + } +} diff --git a/app/core/src/test/resources/timestamp/doc-timestamped-tampered.pdf b/app/core/src/test/resources/timestamp/doc-timestamped-tampered.pdf new file mode 100644 index 0000000000000000000000000000000000000000..3616a4cb130d5896535cb9007b4072d65cac3144 GIT binary patch literal 21985 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 h)>-1t^Th0Jd&IWaH@jKJb%)@^9EZkp~EARjS literal 0 HcmV?d00001 diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 49dfb66bc4..4bacc1b22a 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -7475,6 +7475,10 @@ label = "Redact sensitive information" desc = "Removes hidden JavaScript so nothing can run automatically when the document is opened." label = "Strip active content" +[portal.policies.wizard.capability.timestampPdf] +desc = "Proves the document existed in this exact form at a point in time, using an independent timestamp authority. Only a hash is sent - the document never leaves your server." +label = "Add a trusted timestamp" + [portal.policies.wizard.capability.watermark] desc = "Stamps a visible mark (e.g. “Confidential”) across every page." label = "Apply a watermark" diff --git a/frontend/editor/src/core/types/validateSignature.ts b/frontend/editor/src/core/types/validateSignature.ts index 7bd48347c1..4e18eaec7a 100644 --- a/frontend/editor/src/core/types/validateSignature.ts +++ b/frontend/editor/src/core/types/validateSignature.ts @@ -8,7 +8,7 @@ export interface SignatureValidationBackendResult { coversEntireDocument?: boolean | null; // false = content appended after signing revocationChecked?: boolean | null; revocationStatus?: string | null; // "not-checked" | "good" | "revoked" | "soft-fail" | "unknown" - validationTimeSource?: string | null; // "current" | "signing-time" | "timestamp" + validationTimeSource?: string | null; // "current" | "signing-time" | "timestamp" | "document-timestamp" signerName?: string | null; signatureDate?: string | null; reason?: string | null; @@ -37,7 +37,7 @@ export interface SignatureValidationSignature { coversEntireDocument?: boolean | null; // false = content appended after signing revocationChecked?: boolean | null; revocationStatus?: string | null; // "not-checked" | "good" | "revoked" | "soft-fail" | "unknown" - validationTimeSource?: string | null; // "current" | "signing-time" | "timestamp" + validationTimeSource?: string | null; // "current" | "signing-time" | "timestamp" | "document-timestamp" signerName: string; signatureDate: string; reason: string; diff --git a/frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx b/frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx index 582332518c..6c5d73786c 100644 --- a/frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx +++ b/frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx @@ -114,6 +114,14 @@ const CAPABILITY_META: Record< descEn: "Removes hidden JavaScript so nothing can run automatically when the document is opened.", }, + + timestampPdf: { + labelKey: "portal.policies.wizard.capability.timestampPdf.label", + labelEn: "Add a trusted timestamp", + descKey: "portal.policies.wizard.capability.timestampPdf.desc", + descEn: + "Proves the document existed in this exact form at a point in time, using an independent timestamp authority. Only a hash is sent - the document never leaves your server.", + }, watermark: { labelKey: "portal.policies.wizard.capability.watermark.label", labelEn: "Apply a watermark", diff --git a/frontend/editor/src/proprietary/policies/operations.test.ts b/frontend/editor/src/proprietary/policies/operations.test.ts index 253e9ae6bd..30326722e6 100644 --- a/frontend/editor/src/proprietary/policies/operations.test.ts +++ b/frontend/editor/src/proprietary/policies/operations.test.ts @@ -21,6 +21,7 @@ describe("POLICY_OPERATIONS", () => { "ocr", "redact", "sanitize", + "timestampPdf", "watermark", ]); for (const id of ALL_TOOL_IDS) { diff --git a/frontend/editor/src/proprietary/policies/operations.ts b/frontend/editor/src/proprietary/policies/operations.ts index 5d07df1717..315f1a463a 100644 --- a/frontend/editor/src/proprietary/policies/operations.ts +++ b/frontend/editor/src/proprietary/policies/operations.ts @@ -7,6 +7,7 @@ import { describeToolOperation } from "@app/hooks/tools/shared/toolOperationDescriptor"; import { redactOperationConfig } from "@app/hooks/tools/redact/useRedactOperation"; import { sanitizeOperationConfig } from "@app/hooks/tools/sanitize/useSanitizeOperation"; +import { timestampPdfOperationConfig } from "@app/hooks/tools/timestampPdf/useTimestampPdfOperation"; import { addWatermarkOperationConfig } from "@app/hooks/tools/addWatermark/useAddWatermarkOperation"; import { ocrOperationConfig } from "@app/hooks/tools/ocr/useOCROperation"; import { flattenOperationConfig } from "@app/hooks/tools/flatten/useFlattenOperation"; @@ -53,6 +54,12 @@ export const POLICY_OPERATIONS = { "/api/v1/security/sanitize-pdf", sanitizeOperationConfig, ), + // RFC 3161 timestamp. Already a SISO tool; surfacing it here is what makes a signature durable + // in a pipeline (PAdES-LTV), and only a SHA-256 hash reaches the TSA - never the document. + timestampPdf: describeToolOperation( + "/api/v1/security/timestamp-pdf", + timestampPdfOperationConfig, + ), watermark: describeToolOperation( "/api/v1/security/add-watermark", addWatermarkOperationConfig,