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.
This commit is contained in:
Anthony Stirling
2026-07-21 17:37:54 +00:00
committed by GitHub
parent 5e3e89ccb2
commit 621731bda1
9 changed files with 179 additions and 8 deletions
@@ -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<X509CertificateHolder> 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.
*
* <p>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());
}
}
@@ -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).
*
* <p>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<SignatureValidationResult> results = controller.validateSignature(request).getBody();
assertThat(results).hasSize(1);
return results.get(0);
}
}
@@ -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"
@@ -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;
@@ -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",
@@ -21,6 +21,7 @@ describe("POLICY_OPERATIONS", () => {
"ocr",
"redact",
"sanitize",
"timestampPdf",
"watermark",
]);
for (const id of ALL_TOOL_IDS) {
@@ -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,