mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
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:
+66
-6
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
+91
@@ -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);
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user