Merge branch 'V2' of github.com:Stirling-Tools/Stirling-PDF into chore/V2/RightRailCleanup

This commit is contained in:
EthanHealy01 2025-10-16 14:43:50 +01:00
commit d38b230d85
46 changed files with 5096 additions and 265 deletions

View File

@ -120,6 +120,7 @@ public class ApplicationProperties {
private String loginMethod = "all";
private String customGlobalAPIKey;
private Jwt jwt = new Jwt();
private Validation validation = new Validation();
public Boolean isAltLogin() {
return saml2.getEnabled() || oauth2.getEnabled();
@ -308,6 +309,41 @@ public class ApplicationProperties {
private int keyRetentionDays = 7;
private boolean secureCookie;
}
@Data
public static class Validation {
private Trust trust = new Trust();
private boolean allowAIA = false;
private Aatl aatl = new Aatl();
private Eutl eutl = new Eutl();
private Revocation revocation = new Revocation();
@Data
public static class Trust {
private boolean serverAsAnchor = true;
private boolean useSystemTrust = false;
private boolean useMozillaBundle = false;
private boolean useAATL = false;
private boolean useEUTL = false;
}
@Data
public static class Aatl {
private String url = "https://trustlist.adobe.com/tl.pdf";
}
@Data
public static class Eutl {
private String lotlUrl = "https://ec.europa.eu/tools/lotl/eu-lotl.xml";
private boolean acceptTransitional = false;
}
@Data
public static class Revocation {
private String mode = "none";
private boolean hardFail = false;
}
}
}
@Data

View File

@ -5,9 +5,11 @@ import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.security.cert.PKIXCertPathBuilderResult;
import java.security.cert.X509Certificate;
import java.security.interfaces.RSAPublicKey;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Date;
import java.util.List;
@ -32,6 +34,7 @@ import org.springframework.web.multipart.MultipartFile;
import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.swagger.JsonDataResponse;
import stirling.software.SPDF.model.api.security.SignatureValidationRequest;
@ -42,6 +45,7 @@ import stirling.software.common.annotations.api.SecurityApi;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
@Slf4j
@SecurityApi
@RequiredArgsConstructor
public class ValidateSignatureController {
@ -65,8 +69,9 @@ public class ValidateSignatureController {
@Operation(
summary = "Validate PDF Digital Signature",
description =
"Validates the digital signatures in a PDF file against default or custom"
+ " certificates. Input:PDF Output:JSON Type:SISO")
"Validates the digital signatures in a PDF file using PKIX path building"
+ " and time-of-signing semantics. Supports custom trust anchors."
+ " Input:PDF Output:JSON Type:SISO")
@AutoJobPostMapping(
value = "/validate-signature",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@ -74,12 +79,12 @@ public class ValidateSignatureController {
@ModelAttribute SignatureValidationRequest request) throws IOException {
List<SignatureValidationResult> results = new ArrayList<>();
MultipartFile file = request.getFileInput();
MultipartFile certFile = request.getCertFile();
// Load custom certificate if provided
X509Certificate customCert = null;
if (certFile != null && !certFile.isEmpty()) {
try (ByteArrayInputStream certStream = new ByteArrayInputStream(certFile.getBytes())) {
if (request.getCertFile() != null && !request.getCertFile().isEmpty()) {
try (ByteArrayInputStream certStream =
new ByteArrayInputStream(request.getCertFile().getBytes())) {
CertificateFactory cf = CertificateFactory.getInstance("X.509");
customCert = (X509Certificate) cf.generateCertificate(certStream);
} catch (CertificateException e) {
@ -108,67 +113,150 @@ public class ValidateSignatureController {
Store<X509CertificateHolder> certStore = signedData.getCertificates();
SignerInformationStore signerStore = signedData.getSignerInfos();
for (SignerInformation signer : signerStore.getSigners()) {
for (SignerInformation signerInfo : signerStore.getSigners()) {
X509CertificateHolder certHolder =
(X509CertificateHolder)
certStore.getMatches(signer.getSID()).iterator().next();
X509Certificate cert =
certStore.getMatches(signerInfo.getSID()).iterator().next();
X509Certificate signerCert =
new JcaX509CertificateConverter().getCertificate(certHolder);
boolean isValid =
signer.verify(new JcaSimpleSignerInfoVerifierBuilder().build(cert));
result.setValid(isValid);
// Extract intermediate certificates from CMS
Collection<X509Certificate> intermediates =
certValidationService.extractIntermediateCertificates(
certStore, signerCert);
// Additional validations
result.setChainValid(
customCert != null
? certValidationService
.validateCertificateChainWithCustomCert(
cert, customCert)
: certValidationService.validateCertificateChain(cert));
// Log what we found
log.debug(
"Found {} intermediate certificates in CMS signature",
intermediates.size());
for (X509Certificate inter : intermediates) {
log.debug(
" → Intermediate: {}",
inter.getSubjectX500Principal().getName());
log.debug(
" Issuer DN: {}", inter.getIssuerX500Principal().getName());
}
result.setTrustValid(
customCert != null
? certValidationService.validateTrustWithCustomCert(
cert, customCert)
: certValidationService.validateTrustStore(cert));
// Determine validation time (TSA timestamp or signingTime, or current)
CertificateValidationService.ValidationTime validationTimeResult =
certValidationService.extractValidationTime(signerInfo);
Date validationTime;
if (validationTimeResult == null) {
validationTime = new Date();
result.setValidationTimeSource("current");
} else {
validationTime = validationTimeResult.date;
result.setValidationTimeSource(validationTimeResult.source);
}
result.setNotRevoked(!certValidationService.isRevoked(cert));
result.setNotExpired(!cert.getNotAfter().before(new Date()));
// Verify cryptographic signature
boolean cmsValid =
signerInfo.verify(
new JcaSimpleSignerInfoVerifierBuilder().build(signerCert));
result.setValid(cmsValid);
// Build and validate certificate path
boolean chainValid = false;
boolean trustValid = false;
try {
PKIXCertPathBuilderResult pathResult =
certValidationService.buildAndValidatePath(
signerCert, intermediates, customCert, validationTime);
chainValid = true;
trustValid = true; // Path ends at trust anchor
result.setCertPathLength(
pathResult.getCertPath().getCertificates().size());
} catch (Exception e) {
String errorMsg = e.getMessage();
result.setChainValidationError(errorMsg);
chainValid = false;
trustValid = false;
// Log the full error for debugging
log.warn(
"Certificate path validation failed for {}: {}",
signerCert.getSubjectX500Principal().getName(),
errorMsg);
log.debug("Full stack trace:", e);
}
result.setChainValid(chainValid);
result.setTrustValid(trustValid);
// Check validity at validation time
boolean outside =
certValidationService.isOutsideValidityPeriod(
signerCert, validationTime);
result.setNotExpired(!outside);
// Revocation status determination
boolean revocationEnabled = certValidationService.isRevocationEnabled();
result.setRevocationChecked(revocationEnabled);
if (!revocationEnabled) {
result.setRevocationStatus("not-checked");
} else if (chainValid && trustValid) {
// Path building succeeded with revocation enabled = no revocation found
result.setRevocationStatus("good");
} else if (result.getChainValidationError() != null
&& result.getChainValidationError()
.toLowerCase()
.contains("revocation")) {
// Check if failure was revocation-related
if (result.getChainValidationError()
.toLowerCase()
.contains("unable to check")) {
result.setRevocationStatus("soft-fail");
} else {
result.setRevocationStatus("revoked");
}
} else {
result.setRevocationStatus("unknown");
}
// Set basic signature info
result.setSignerName(sig.getName());
result.setSignatureDate(sig.getSignDate().getTime().toString());
result.setSignatureDate(
sig.getSignDate() != null
? sig.getSignDate().getTime().toString()
: null);
result.setReason(sig.getReason());
result.setLocation(sig.getLocation());
// Set new certificate details
result.setIssuerDN(cert.getIssuerX500Principal().getName());
result.setSubjectDN(cert.getSubjectX500Principal().getName());
result.setSerialNumber(cert.getSerialNumber().toString(16)); // Hex format
result.setValidFrom(cert.getNotBefore().toString());
result.setValidUntil(cert.getNotAfter().toString());
result.setSignatureAlgorithm(cert.getSigAlgName());
// Set certificate details (from signer cert)
result.setIssuerDN(signerCert.getIssuerX500Principal().getName());
result.setSubjectDN(signerCert.getSubjectX500Principal().getName());
result.setSerialNumber(
signerCert.getSerialNumber().toString(16)); // Hex format
result.setValidFrom(signerCert.getNotBefore().toString());
result.setValidUntil(signerCert.getNotAfter().toString());
result.setSignatureAlgorithm(signerCert.getSigAlgName());
// Get key size (if possible)
try {
result.setKeySize(
((RSAPublicKey) cert.getPublicKey()).getModulus().bitLength());
((RSAPublicKey) signerCert.getPublicKey())
.getModulus()
.bitLength());
} catch (Exception e) {
// If not RSA or error, set to 0
result.setKeySize(0);
}
result.setVersion(String.valueOf(cert.getVersion()));
result.setVersion(String.valueOf(signerCert.getVersion()));
// Set key usage
List<String> keyUsages = new ArrayList<>();
boolean[] keyUsageFlags = cert.getKeyUsage();
boolean[] keyUsageFlags = signerCert.getKeyUsage();
if (keyUsageFlags != null) {
String[] keyUsageLabels = {
"Digital Signature", "Non-Repudiation", "Key Encipherment",
"Data Encipherment", "Key Agreement", "Certificate Signing",
"CRL Signing", "Encipher Only", "Decipher Only"
"Digital Signature",
"Non-Repudiation",
"Key Encipherment",
"Data Encipherment",
"Key Agreement",
"Certificate Signing",
"CRL Signing",
"Encipher Only",
"Decipher Only"
};
for (int i = 0; i < keyUsageFlags.length; i++) {
if (keyUsageFlags[i]) {
@ -178,10 +266,8 @@ public class ValidateSignatureController {
}
result.setKeyUsages(keyUsages);
// Check if self-signed
result.setSelfSigned(
cert.getSubjectX500Principal()
.equals(cert.getIssuerX500Principal()));
// Check if self-signed (properly)
result.setSelfSigned(certValidationService.isSelfSigned(signerCert));
}
} catch (Exception e) {
result.setValid(false);

View File

@ -6,17 +6,32 @@ import lombok.Data;
@Data
public class SignatureValidationResult {
// Cryptographic signature validation
private boolean valid;
// Certificate chain validation
private boolean chainValid;
private boolean trustValid;
private String chainValidationError;
private int certPathLength;
// Time validation
private boolean notExpired;
// Revocation validation
private boolean revocationChecked; // true if PKIX revocation was enabled
private String revocationStatus; // "not-checked" | "good" | "revoked" | "soft-fail" | "unknown"
private String validationTimeSource; // "current", "signing-time", or "timestamp"
// Signature metadata
private String signerName;
private String signatureDate;
private String reason;
private String location;
private String errorMessage;
private boolean chainValid;
private boolean trustValid;
private boolean notExpired;
private boolean notRevoked;
// Certificate details
private String issuerDN; // Certificate issuer's Distinguished Name
private String subjectDN; // Certificate subject's Distinguished Name
private String serialNumber; // Certificate serial number

View File

@ -1,143 +1,863 @@
package stirling.software.SPDF.service;
import java.io.*;
import java.net.HttpURLConnection;
import java.net.URL;
import java.security.GeneralSecurityException;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.MessageDigest;
import java.security.cert.*;
import java.util.*;
import org.springframework.stereotype.Service;
import javax.net.ssl.TrustManager;
import javax.net.ssl.TrustManagerFactory;
import javax.net.ssl.X509TrustManager;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import io.github.pixee.security.BoundedLineReader;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentNameDictionary;
import org.apache.pdfbox.pdmodel.PDEmbeddedFilesNameTreeNode;
import org.apache.pdfbox.pdmodel.common.filespecification.PDComplexFileSpecification;
import org.apache.pdfbox.pdmodel.common.filespecification.PDEmbeddedFile;
import org.bouncycastle.asn1.ASN1Encodable;
import org.bouncycastle.asn1.ASN1GeneralizedTime;
import org.bouncycastle.asn1.ASN1ObjectIdentifier;
import org.bouncycastle.asn1.ASN1UTCTime;
import org.bouncycastle.asn1.cms.CMSAttributes;
import org.bouncycastle.cert.X509CertificateHolder;
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
import org.bouncycastle.cms.CMSSignedData;
import org.bouncycastle.cms.SignerInformation;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.tsp.TimeStampToken;
import org.bouncycastle.util.Store;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.w3c.dom.Document;
import org.w3c.dom.NodeList;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.ServerCertificateServiceInterface;
@Service
@Slf4j
public class CertificateValidationService {
private KeyStore trustStore;
/**
* Result container for validation time extraction Contains both the date and the source of the
* time
*/
public static class ValidationTime {
public final Date date;
public final String source; // "timestamp" | "signing-time" | "current"
public ValidationTime(Date date, String source) {
this.date = date;
this.source = source;
}
}
// Separate trust stores: signing vs TLS
private KeyStore signingTrustAnchors; // AATL/EUTL + server cert for PDF signing
private final ServerCertificateServiceInterface serverCertificateService;
private final ApplicationProperties applicationProperties;
// EUTL (EU Trusted List) constants
private static final String NS_TSL = "http://uri.etsi.org/02231/v2#";
// Qualified CA service types to import as trust anchors (per ETSI TS 119 612)
private static final Set<String> EUTL_SERVICE_TYPES =
new HashSet<>(
Arrays.asList(
"http://uri.etsi.org/TrstSvc/Svctype/CA/QC",
"http://uri.etsi.org/TrstSvc/Svctype/NationalRootCA-QC"));
// Active statuses to accept (per ETSI TS 119 612)
private static final String STATUS_UNDER_SUPERVISION =
"http://uri.etsi.org/TrstSvc/TrustedList/Svcstatus/undersupervision";
private static final String STATUS_ACCREDITED =
"http://uri.etsi.org/TrstSvc/TrustedList/Svcstatus/accredited";
private static final String STATUS_SUPERVISION_IN_CESSATION =
"http://uri.etsi.org/TrstSvc/TrustedList/Svcstatus/supervisionincessation";
static {
if (java.security.Security.getProvider("BC") == null) {
java.security.Security.addProvider(new BouncyCastleProvider());
}
}
public CertificateValidationService(
@Autowired(required = false) ServerCertificateServiceInterface serverCertificateService,
ApplicationProperties applicationProperties) {
this.serverCertificateService = serverCertificateService;
this.applicationProperties = applicationProperties;
}
@PostConstruct
private void initializeTrustStore() throws Exception {
trustStore = KeyStore.getInstance(KeyStore.getDefaultType());
trustStore.load(null, null);
loadMozillaCertificates();
signingTrustAnchors = KeyStore.getInstance(KeyStore.getDefaultType());
signingTrustAnchors.load(null, null);
ApplicationProperties.Security.Validation validation =
applicationProperties.getSecurity().getValidation();
// Enable JDK fetching of OCSP/CRLDP if allowed
if (validation.isAllowAIA()) {
java.security.Security.setProperty("ocsp.enable", "true");
System.setProperty("com.sun.security.enableCRLDP", "true");
System.setProperty("com.sun.security.enableAIAcaIssuers", "true");
log.info("Enabled AIA certificate fetching and revocation checking");
}
// Trust only what we explicitly opt into:
if (validation.getTrust().isServerAsAnchor()) loadServerCertAsAnchor();
if (validation.getTrust().isUseSystemTrust()) loadJavaSystemTrustStore();
if (validation.getTrust().isUseMozillaBundle()) loadBundledMozillaCACerts();
if (validation.getTrust().isUseAATL()) loadAATLCertificates();
if (validation.getTrust().isUseEUTL()) loadEUTLCertificates();
}
private void loadMozillaCertificates() throws Exception {
try (InputStream is = getClass().getResourceAsStream("/certdata.txt")) {
BufferedReader reader = new BufferedReader(new InputStreamReader(is));
String line;
StringBuilder certData = new StringBuilder();
boolean inCert = false;
int certCount = 0;
/**
* Core entry-point: build a valid PKIX path from signerCert using provided intermediates
*
* @param signerCert The signer certificate
* @param intermediates Collection of intermediate certificates from CMS
* @param customTrustAnchor Optional custom root/intermediate certificate
* @param validationTime Time to validate at (signing time or current)
* @return PKIXCertPathBuilderResult containing validated path
* @throws GeneralSecurityException if path building/validation fails
*/
public PKIXCertPathBuilderResult buildAndValidatePath(
X509Certificate signerCert,
Collection<X509Certificate> intermediates,
X509Certificate customTrustAnchor,
Date validationTime)
throws GeneralSecurityException {
while ((line = BoundedLineReader.readLine(reader, 5_000_000)) != null) {
if (line.startsWith("CKA_VALUE MULTILINE_OCTAL")) {
inCert = true;
certData = new StringBuilder();
continue;
// Build trust anchors
Set<TrustAnchor> anchors = new HashSet<>();
if (customTrustAnchor != null) {
anchors.add(new TrustAnchor(customTrustAnchor, null));
} else {
Enumeration<String> aliases = signingTrustAnchors.aliases();
while (aliases.hasMoreElements()) {
Certificate c = signingTrustAnchors.getCertificate(aliases.nextElement());
if (c instanceof X509Certificate x) {
anchors.add(new TrustAnchor(x, null));
}
if (inCert) {
if ("END".equals(line)) {
inCert = false;
byte[] certBytes = parseOctalData(certData.toString());
if (certBytes != null) {
CertificateFactory cf = CertificateFactory.getInstance("X.509");
X509Certificate cert =
(X509Certificate)
cf.generateCertificate(
new ByteArrayInputStream(certBytes));
trustStore.setCertificateEntry("mozilla-cert-" + certCount++, cert);
}
} else {
certData.append(line).append("\n");
}
}
if (anchors.isEmpty()) {
throw new CertPathBuilderException("No trust anchors available");
}
// Target certificate selector
X509CertSelector target = new X509CertSelector();
target.setCertificate(signerCert);
// Intermediate certificate store
List<Certificate> allCerts = new ArrayList<>(intermediates);
CertStore intermediateStore =
CertStore.getInstance("Collection", new CollectionCertStoreParameters(allCerts));
// PKIX parameters
PKIXBuilderParameters params = new PKIXBuilderParameters(anchors, target);
params.addCertStore(intermediateStore);
String revocationMode =
applicationProperties.getSecurity().getValidation().getRevocation().getMode();
params.setRevocationEnabled(!"none".equalsIgnoreCase(revocationMode));
if (validationTime != null) {
params.setDate(validationTime);
}
// Revocation checking
if (!"none".equalsIgnoreCase(revocationMode)) {
try {
PKIXRevocationChecker rc =
(PKIXRevocationChecker)
CertPathValidator.getInstance("PKIX").getRevocationChecker();
Set<PKIXRevocationChecker.Option> options =
EnumSet.noneOf(PKIXRevocationChecker.Option.class);
// Soft-fail: allow validation to succeed if revocation status unavailable
boolean revocationHardFail =
applicationProperties
.getSecurity()
.getValidation()
.getRevocation()
.isHardFail();
if (!revocationHardFail) {
options.add(PKIXRevocationChecker.Option.SOFT_FAIL);
}
// Revocation mode configuration
if ("ocsp".equalsIgnoreCase(revocationMode)) {
// OCSP-only: prefer OCSP (default), disable fallback to CRL
options.add(PKIXRevocationChecker.Option.NO_FALLBACK);
} else if ("crl".equalsIgnoreCase(revocationMode)) {
// CRL-only: prefer CRLs, disable fallback to OCSP
options.add(PKIXRevocationChecker.Option.PREFER_CRLS);
options.add(PKIXRevocationChecker.Option.NO_FALLBACK);
}
// "ocsp+crl" or other: use defaults (try OCSP first, fallback to CRL)
rc.setOptions(options);
params.addCertPathChecker(rc);
} catch (Exception e) {
log.warn("Failed to configure revocation checker: {}", e.getMessage());
}
}
// Build path
CertPathBuilder builder = CertPathBuilder.getInstance("PKIX");
return (PKIXCertPathBuilderResult) builder.build(params);
}
/**
* Extract validation time from signature (TSA timestamp or signingTime)
*
* @param signerInfo The CMS signer information
* @return ValidationTime containing date and source, or null if not found
*/
public ValidationTime extractValidationTime(SignerInformation signerInfo) {
try {
// 1) Check for timestamp token (RFC 3161) - highest priority
var unsignedAttrs = signerInfo.getUnsignedAttributes();
if (unsignedAttrs != null) {
var attr =
unsignedAttrs.get(new ASN1ObjectIdentifier("1.2.840.113549.1.9.16.2.14"));
if (attr != null) {
try {
TimeStampToken tst =
new TimeStampToken(
new CMSSignedData(
attr.getAttributeValues()[0]
.toASN1Primitive()
.getEncoded()));
Date tstTime = tst.getTimeStampInfo().getGenTime();
log.debug("Using timestamp token time: {}", tstTime);
return new ValidationTime(tstTime, "timestamp");
} catch (Exception e) {
log.debug("Failed to parse timestamp token: {}", e.getMessage());
}
}
}
}
}
private byte[] parseOctalData(String data) {
try {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
String[] tokens = data.split("\\\\");
for (String token : tokens) {
token = token.trim();
if (!token.isEmpty()) {
baos.write(Integer.parseInt(token, 8));
// 2) Check for signingTime attribute - fallback
var signedAttrs = signerInfo.getSignedAttributes();
if (signedAttrs != null) {
var st = signedAttrs.get(CMSAttributes.signingTime);
if (st != null) {
ASN1Encodable val = st.getAttributeValues()[0];
Date signingTime = null;
if (val instanceof ASN1UTCTime ut) {
signingTime = ut.getDate();
} else if (val instanceof ASN1GeneralizedTime gt) {
signingTime = gt.getDate();
}
if (signingTime != null) {
log.debug("Using signingTime attribute: {}", signingTime);
return new ValidationTime(signingTime, "signing-time");
}
}
}
return baos.toByteArray();
} catch (Exception e) {
return null;
log.debug("Error extracting validation time: {}", e.getMessage());
}
return null;
}
public boolean validateCertificateChain(X509Certificate cert) {
/**
* Check if certificate is outside validity period at given time
*
* @param cert Certificate to check
* @param at Time to check validity
* @return true if certificate is expired or not yet valid
*/
public boolean isOutsideValidityPeriod(X509Certificate cert, Date at) {
try {
CertPathValidator validator = CertPathValidator.getInstance("PKIX");
CertificateFactory cf = CertificateFactory.getInstance("X.509");
List<X509Certificate> certList = Arrays.asList(cert);
CertPath certPath = cf.generateCertPath(certList);
Set<TrustAnchor> anchors = new HashSet<>();
Enumeration<String> aliases = trustStore.aliases();
while (aliases.hasMoreElements()) {
Object trustCert = trustStore.getCertificate(aliases.nextElement());
if (trustCert instanceof X509Certificate x509Cert) {
anchors.add(new TrustAnchor(x509Cert, null));
}
}
PKIXParameters params = new PKIXParameters(anchors);
params.setRevocationEnabled(false);
validator.validate(certPath, params);
return true;
} catch (Exception e) {
return false;
}
}
public boolean validateTrustStore(X509Certificate cert) {
try {
Enumeration<String> aliases = trustStore.aliases();
while (aliases.hasMoreElements()) {
Object trustCert = trustStore.getCertificate(aliases.nextElement());
if (trustCert instanceof X509Certificate && cert.equals(trustCert)) {
return true;
}
}
return false;
} catch (KeyStoreException e) {
return false;
}
}
public boolean isRevoked(X509Certificate cert) {
try {
cert.checkValidity();
cert.checkValidity(at);
return false;
} catch (CertificateExpiredException | CertificateNotYetValidException e) {
return true;
}
}
public boolean validateCertificateChainWithCustomCert(
X509Certificate cert, X509Certificate customCert) {
/**
* Check if revocation checking is enabled
*
* @return true if revocation mode is not "none"
*/
public boolean isRevocationEnabled() {
String revocationMode =
applicationProperties.getSecurity().getValidation().getRevocation().getMode();
return !"none".equalsIgnoreCase(revocationMode);
}
/**
* Check if certificate is a CA certificate
*
* @param cert Certificate to check
* @return true if certificate has basicConstraints with CA=true
*/
public boolean isCA(X509Certificate cert) {
return cert.getBasicConstraints() >= 0;
}
/**
* Verify if certificate is self-signed by checking signature
*
* @param cert Certificate to check
* @return true if certificate is self-signed and signature is valid
*/
public boolean isSelfSigned(X509Certificate cert) {
try {
cert.verify(customCert.getPublicKey());
if (!cert.getSubjectX500Principal().equals(cert.getIssuerX500Principal())) {
return false;
}
cert.verify(cert.getPublicKey());
return true;
} catch (Exception e) {
return false;
}
}
public boolean validateTrustWithCustomCert(X509Certificate cert, X509Certificate customCert) {
/**
* Calculate SHA-256 fingerprint of certificate
*
* @param cert Certificate
* @return Hex string of SHA-256 hash
*/
public String sha256Fingerprint(X509Certificate cert) {
try {
// Compare the issuer of the signature certificate with the custom certificate
return cert.getIssuerX500Principal().equals(customCert.getSubjectX500Principal());
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] hash = md.digest(cert.getEncoded());
return bytesToHex(hash);
} catch (Exception e) {
return false;
return "";
}
}
private String bytesToHex(byte[] bytes) {
StringBuilder sb = new StringBuilder(bytes.length * 2);
for (byte b : bytes) {
sb.append(String.format("%02X", b));
}
return sb.toString();
}
/**
* Extract all certificates from CMS signature store
*
* @param certStore BouncyCastle certificate store
* @param signerCert The signer certificate
* @return Collection of all certificates except signer
*/
public Collection<X509Certificate> extractIntermediateCertificates(
Store<X509CertificateHolder> certStore, X509Certificate signerCert) {
List<X509Certificate> intermediates = new ArrayList<>();
try {
JcaX509CertificateConverter converter = new JcaX509CertificateConverter();
Collection<X509CertificateHolder> holders = certStore.getMatches(null);
for (X509CertificateHolder holder : holders) {
X509Certificate cert = converter.getCertificate(holder);
if (!cert.equals(signerCert)) {
intermediates.add(cert);
}
}
} catch (Exception e) {
log.debug("Error extracting intermediate certificates: {}", e.getMessage());
}
return intermediates;
}
// ==================== Trust Store Loading ====================
/**
* Load certificates from Java's system trust store (cacerts). On Windows, this includes
* certificates from the Windows trust store. This provides maximum compatibility with what
* browsers and OS trust.
*/
private void loadJavaSystemTrustStore() {
try {
log.info("Loading certificates from Java system trust store");
// Get default trust manager factory
TrustManagerFactory tmf =
TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
tmf.init((KeyStore) null); // null = use system default
// Extract certificates from trust managers
int loadedCount = 0;
for (TrustManager tm : tmf.getTrustManagers()) {
if (tm instanceof X509TrustManager x509tm) {
for (X509Certificate cert : x509tm.getAcceptedIssuers()) {
if (isCA(cert)) {
String fingerprint = sha256Fingerprint(cert);
String alias = "system-" + fingerprint;
signingTrustAnchors.setCertificateEntry(alias, cert);
loadedCount++;
}
}
}
}
log.info("Loaded {} CA certificates from Java system trust store", loadedCount);
} catch (Exception e) {
log.error("Failed to load Java system trust store: {}", e.getMessage(), e);
}
}
/**
* Load bundled Mozilla CA certificate bundle from resources. This bundle contains ~140 trusted
* root CAs from Mozilla's CA Certificate Program, suitable for validating most commercial PDF
* signatures.
*/
private void loadBundledMozillaCACerts() {
try {
log.info("Loading bundled Mozilla CA certificates from resources");
InputStream certStream =
getClass().getClassLoader().getResourceAsStream("certs/cacert.pem");
if (certStream == null) {
log.warn("Bundled Mozilla CA certificate file not found in resources");
return;
}
CertificateFactory cf = CertificateFactory.getInstance("X.509");
Collection<? extends Certificate> certs = cf.generateCertificates(certStream);
certStream.close();
int loadedCount = 0;
int skippedCount = 0;
for (Certificate cert : certs) {
if (cert instanceof X509Certificate x509) {
// Only add CA certificates to trust anchors
if (isCA(x509)) {
String fingerprint = sha256Fingerprint(x509);
String alias = "mozilla-" + fingerprint;
signingTrustAnchors.setCertificateEntry(alias, x509);
loadedCount++;
} else {
skippedCount++;
}
}
}
log.info(
"Loaded {} Mozilla CA certificates as trust anchors (skipped {} non-CA certs)",
loadedCount,
skippedCount);
} catch (Exception e) {
log.error("Failed to load bundled Mozilla CA certificates: {}", e.getMessage(), e);
}
}
private void loadServerCertAsAnchor() {
try {
if (serverCertificateService != null
&& serverCertificateService.isEnabled()
&& serverCertificateService.hasServerCertificate()) {
X509Certificate serverCert = serverCertificateService.getServerCertificate();
// Self-signed certificates can be trust anchors regardless of CA flag
// Non-self-signed certificates should only be trust anchors if they're CAs
boolean selfSigned = isSelfSigned(serverCert);
boolean ca = isCA(serverCert);
if (selfSigned || ca) {
signingTrustAnchors.setCertificateEntry("server-anchor", serverCert);
log.info(
"Loaded server certificate as trust anchor (self-signed: {}, CA: {})",
selfSigned,
ca);
} else {
log.warn(
"Server certificate is neither self-signed nor a CA; not adding as trust anchor");
}
}
} catch (Exception e) {
log.warn("Failed loading server certificate as anchor: {}", e.getMessage());
}
}
/** Download and parse Adobe Approved Trust List (AATL) and add CA certs as trust anchors. */
private void loadAATLCertificates() {
try {
String aatlUrl = applicationProperties.getSecurity().getValidation().getAatl().getUrl();
log.info("Loading Adobe Approved Trust List (AATL) from: {}", aatlUrl);
byte[] pdfBytes = downloadTrustList(aatlUrl);
if (pdfBytes == null) {
log.warn("AATL download returned no data");
return;
}
int added = parseAATLPdf(pdfBytes);
log.info("Loaded {} AATL CA certificates into signing trust", added);
} catch (Exception e) {
log.warn("Failed to load AATL: {}", e.getMessage());
log.debug("AATL loading error", e);
}
}
/** Simple HTTP(S) fetch with sane timeouts. */
private byte[] downloadTrustList(String urlStr) {
HttpURLConnection conn = null;
try {
URL url = new URL(urlStr);
conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
conn.setConnectTimeout(10_000);
conn.setReadTimeout(30_000);
conn.setInstanceFollowRedirects(true);
int code = conn.getResponseCode();
if (code == HttpURLConnection.HTTP_OK) {
try (InputStream in = conn.getInputStream();
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
byte[] buf = new byte[8192];
int r;
while ((r = in.read(buf)) != -1) out.write(buf, 0, r);
return out.toByteArray();
}
} else {
log.warn("AATL download failed: HTTP {}", code);
return null;
}
} catch (Exception e) {
log.warn("AATL download error: {}", e.getMessage());
return null;
} finally {
if (conn != null) conn.disconnect();
}
}
/**
* Parse AATL PDF, extract the embedded "SecuritySettings.xml", and import CA certs. Returns the
* number of newly-added CA certificates.
*/
private int parseAATLPdf(byte[] pdfBytes) throws Exception {
try (PDDocument doc = Loader.loadPDF(pdfBytes)) {
PDDocumentNameDictionary names = doc.getDocumentCatalog().getNames();
if (names == null) {
log.warn("AATL PDF has no name dictionary");
return 0;
}
PDEmbeddedFilesNameTreeNode efRoot = names.getEmbeddedFiles();
if (efRoot == null) {
log.warn("AATL PDF has no embedded files");
return 0;
}
// 1) Try names at root level
Map<String, PDComplexFileSpecification> top = efRoot.getNames();
if (top != null) {
Integer count = tryParseSecuritySettingsXML(top);
if (count != null) return count;
}
// 2) Traverse kids (name-tree)
@SuppressWarnings("unchecked")
List<?> kids = efRoot.getKids();
if (kids != null) {
for (Object kidObj : kids) {
if (kidObj instanceof PDEmbeddedFilesNameTreeNode) {
PDEmbeddedFilesNameTreeNode kid = (PDEmbeddedFilesNameTreeNode) kidObj;
Map<String, PDComplexFileSpecification> map = kid.getNames();
if (map != null) {
Integer count = tryParseSecuritySettingsXML(map);
if (count != null) return count;
}
}
}
}
log.warn("AATL PDF did not contain SecuritySettings.xml");
return 0;
}
}
/**
* Try to locate "SecuritySettings.xml" in the given name map. If found and parsed, returns the
* number of certs added; otherwise returns null.
*/
private Integer tryParseSecuritySettingsXML(Map<String, PDComplexFileSpecification> nameMap) {
PDComplexFileSpecification fileSpec = nameMap.get("SecuritySettings.xml");
if (fileSpec == null) return null;
PDEmbeddedFile ef = fileSpec.getEmbeddedFile();
if (ef == null) return null;
try (InputStream xmlStream = ef.createInputStream()) {
return parseSecuritySettingsXML(xmlStream);
} catch (Exception e) {
log.warn("Failed parsing SecuritySettings.xml: {}", e.getMessage());
log.debug("SecuritySettings.xml parse error", e);
return null;
}
}
/**
* Parse the SecuritySettings.xml and load only CA certificates (basicConstraints >= 0). Returns
* the number of newly-added CA certificates.
*/
private int parseSecuritySettingsXML(InputStream xmlStream) throws Exception {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
factory.setXIncludeAware(false);
factory.setExpandEntityReferences(false);
DocumentBuilder builder = factory.newDocumentBuilder();
Document doc = builder.parse(xmlStream);
NodeList certNodes = doc.getElementsByTagName("Certificate");
CertificateFactory cf = CertificateFactory.getInstance("X.509");
int added = 0;
for (int i = 0; i < certNodes.getLength(); i++) {
String base64 = certNodes.item(i).getTextContent().trim();
if (base64.isEmpty()) continue;
try {
byte[] certBytes = java.util.Base64.getMimeDecoder().decode(base64);
X509Certificate cert =
(X509Certificate)
cf.generateCertificate(new ByteArrayInputStream(certBytes));
// Only add CA certs as anchors
if (isCA(cert)) {
String fingerprint = sha256Fingerprint(cert);
String alias = "aatl-" + fingerprint;
// avoid duplicates
if (signingTrustAnchors.getCertificate(alias) == null) {
signingTrustAnchors.setCertificateEntry(alias, cert);
added++;
}
} else {
log.debug(
"Skipping non-CA certificate from AATL: {}",
cert.getSubjectX500Principal().getName());
}
} catch (Exception e) {
log.debug("Failed to parse an AATL certificate node: {}", e.getMessage());
}
}
return added;
}
/**
* Download LOTL (List Of Trusted Lists), resolve national TSLs, and import qualified CA
* certificates.
*/
private void loadEUTLCertificates() {
try {
String lotlUrl =
applicationProperties.getSecurity().getValidation().getEutl().getLotlUrl();
log.info("Loading EU Trusted List (LOTL) from: {}", lotlUrl);
byte[] lotlBytes = downloadXml(lotlUrl);
if (lotlBytes == null) {
log.warn("LOTL download returned no data");
return;
}
List<String> tslUrls = parseLotlForTslLocations(lotlBytes);
log.info("Found {} national TSL locations in LOTL", tslUrls.size());
int totalAdded = 0;
for (String tslUrl : tslUrls) {
try {
byte[] tslBytes = downloadXml(tslUrl);
if (tslBytes == null) {
log.warn("TSL download failed: {}", tslUrl);
continue;
}
int added = parseTslAndAddCas(tslBytes, tslUrl);
totalAdded += added;
} catch (Exception e) {
log.warn("Failed to parse TSL {}: {}", tslUrl, e.getMessage());
log.debug("TSL parse error", e);
}
}
log.info("Imported {} qualified CA certificates from EUTL", totalAdded);
} catch (Exception e) {
log.warn("EUTL load failed: {}", e.getMessage());
log.debug("EUTL load error", e);
}
}
/** HTTP(S) GET for XML with sane timeouts. */
private byte[] downloadXml(String urlStr) {
HttpURLConnection conn = null;
try {
URL url = new URL(urlStr);
conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
conn.setConnectTimeout(10_000);
conn.setReadTimeout(30_000);
conn.setInstanceFollowRedirects(true);
int code = conn.getResponseCode();
if (code == HttpURLConnection.HTTP_OK) {
try (InputStream in = conn.getInputStream();
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
byte[] buf = new byte[8192];
int r;
while ((r = in.read(buf)) != -1) out.write(buf, 0, r);
return out.toByteArray();
}
} else {
log.warn("XML download failed: HTTP {} for {}", code, urlStr);
return null;
}
} catch (Exception e) {
log.warn("XML download error for {}: {}", urlStr, e.getMessage());
return null;
} finally {
if (conn != null) conn.disconnect();
}
}
/** Parse LOTL and return all TSL URLs from PointersToOtherTSL. */
private List<String> parseLotlForTslLocations(byte[] lotlBytes) throws Exception {
DocumentBuilderFactory dbf = secureDbfWithNamespaces();
DocumentBuilder db = dbf.newDocumentBuilder();
Document doc = db.parse(new ByteArrayInputStream(lotlBytes));
List<String> out = new ArrayList<>();
NodeList ptrs = doc.getElementsByTagNameNS(NS_TSL, "PointersToOtherTSL");
if (ptrs.getLength() == 0) return out;
org.w3c.dom.Element ptrRoot = (org.w3c.dom.Element) ptrs.item(0);
NodeList locations = ptrRoot.getElementsByTagNameNS(NS_TSL, "TSLLocation");
for (int i = 0; i < locations.getLength(); i++) {
String url = locations.item(i).getTextContent().trim();
if (!url.isEmpty()) out.add(url);
}
return out;
}
/**
* Parse a single national TSL, import CA certificates for qualified services in an active
* status. Returns count of newly added CA certs.
*/
private int parseTslAndAddCas(byte[] tslBytes, String sourceUrl) throws Exception {
DocumentBuilderFactory dbf = secureDbfWithNamespaces();
DocumentBuilder db = dbf.newDocumentBuilder();
Document doc = db.parse(new ByteArrayInputStream(tslBytes));
int added = 0;
NodeList services = doc.getElementsByTagNameNS(NS_TSL, "TSPService");
for (int i = 0; i < services.getLength(); i++) {
org.w3c.dom.Element svc = (org.w3c.dom.Element) services.item(i);
org.w3c.dom.Element info = firstChildNS(svc, "ServiceInformation");
if (info == null) continue;
String type = textOf(info, "ServiceTypeIdentifier");
if (!EUTL_SERVICE_TYPES.contains(type)) continue;
String status = textOf(info, "ServiceStatus");
if (!isActiveStatus(status)) continue;
org.w3c.dom.Element sdi = firstChildNS(info, "ServiceDigitalIdentity");
if (sdi == null) continue;
NodeList digitalIds = sdi.getElementsByTagNameNS(NS_TSL, "DigitalId");
for (int d = 0; d < digitalIds.getLength(); d++) {
org.w3c.dom.Element did = (org.w3c.dom.Element) digitalIds.item(d);
NodeList certNodes = did.getElementsByTagNameNS(NS_TSL, "X509Certificate");
for (int c = 0; c < certNodes.getLength(); c++) {
String base64 = certNodes.item(c).getTextContent().trim();
if (base64.isEmpty()) continue;
try {
byte[] certBytes = java.util.Base64.getMimeDecoder().decode(base64);
CertificateFactory cf = CertificateFactory.getInstance("X.509");
X509Certificate cert =
(X509Certificate)
cf.generateCertificate(new ByteArrayInputStream(certBytes));
if (!isCA(cert)) {
log.debug(
"Skipping non-CA in TSL {}: {}",
sourceUrl,
cert.getSubjectX500Principal().getName());
continue;
}
String fp = sha256Fingerprint(cert);
String alias = "eutl-" + fp;
if (signingTrustAnchors.getCertificate(alias) == null) {
signingTrustAnchors.setCertificateEntry(alias, cert);
added++;
}
} catch (Exception e) {
log.debug(
"Failed to import a certificate from {}: {}",
sourceUrl,
e.getMessage());
}
}
}
}
log.debug("TSL {} → imported {} CA certificates", sourceUrl, added);
return added;
}
/** Check if service status is active (per ETSI TS 119 612). */
private boolean isActiveStatus(String statusUri) {
if (STATUS_UNDER_SUPERVISION.equals(statusUri)) return true;
if (STATUS_ACCREDITED.equals(statusUri)) return true;
boolean acceptTransitional =
applicationProperties
.getSecurity()
.getValidation()
.getEutl()
.isAcceptTransitional();
if (acceptTransitional && STATUS_SUPERVISION_IN_CESSATION.equals(statusUri)) return true;
return false;
}
/** Create secure DocumentBuilderFactory with namespace awareness. */
private DocumentBuilderFactory secureDbfWithNamespaces() throws Exception {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setNamespaceAware(true);
// Secure processing hardening
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
factory.setXIncludeAware(false);
factory.setExpandEntityReferences(false);
return factory;
}
/** Get first child element with given local name in TSL namespace. */
private org.w3c.dom.Element firstChildNS(org.w3c.dom.Element parent, String localName) {
NodeList nl = parent.getElementsByTagNameNS(NS_TSL, localName);
return (nl.getLength() == 0) ? null : (org.w3c.dom.Element) nl.item(0);
}
/** Get text content of first child with given local name. */
private String textOf(org.w3c.dom.Element parent, String localName) {
org.w3c.dom.Element e = firstChildNS(parent, localName);
return (e == null) ? "" : e.getTextContent().trim();
}
/** Get signing trust store */
public KeyStore getSigningTrustStore() {
return signingTrustAnchors;
}
}

View File

@ -65,6 +65,22 @@ security:
enableKeyCleanup: true # Set to 'true' to enable key pair cleanup
keyRetentionDays: 7 # Number of days to retain old keys. The default is 7 days.
secureCookie: false # Set to 'true' to use secure cookies for JWTs
validation: # PDF signature validation settings
trust:
serverAsAnchor: true # Trust server certificate as anchor for PDF signatures (if configured and self-signed or CA)
useSystemTrust: true # Trust Java/OS system trust store for PDF signature validation
useMozillaBundle: true # Trust bundled Mozilla CA bundle (~140 CAs) for PDF signature validation
useAATL: false # Trust Adobe Approved Trust List (AATL) for PDF signature validation - downloads from Adobe on startup if enabled
useEUTL: false # Trust EU Trusted List (EUTL) for eIDAS qualified certificates - downloads LOTL and national TSLs on startup if enabled
allowAIA: false # Allow JDK to fetch issuer certificates and revocation information from network (OCSP/CRL/AIA)
aatl:
url: https://trustlist.adobe.com/tl.pdf # Adobe Approved Trust List download URL
eutl:
lotlUrl: https://ec.europa.eu/tools/lotl/eu-lotl.xml # EU List Of Trusted Lists (LOTL) URL
acceptTransitional: false # Accept certificates with 'supervisionincessation' status (transitional state)
revocation:
mode: none # Revocation checking mode: 'none' (disabled), 'ocsp' (OCSP only), 'crl' (CRL only), 'ocsp+crl' (OCSP with CRL fallback)
hardFail: false # Fail validation if revocation status cannot be determined (true=strict, false=soft-fail)
premium:
key: 00000000-0000-0000-0000-000000000000

View File

@ -2,20 +2,20 @@ package stirling.software.SPDF.service;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.security.PublicKey;
import java.security.cert.CertificateExpiredException;
import java.security.cert.X509Certificate;
import javax.security.auth.x500.X500Principal;
import java.util.Date;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import stirling.software.common.model.ApplicationProperties;
/** Tests for the CertificateValidationService using mocked certificates. */
class CertificateValidationServiceTest {
@ -26,121 +26,67 @@ class CertificateValidationServiceTest {
@BeforeEach
void setUp() throws Exception {
validationService = new CertificateValidationService();
// Create mock ApplicationProperties with default validation settings
ApplicationProperties applicationProperties = mock(ApplicationProperties.class);
ApplicationProperties.Security security = mock(ApplicationProperties.Security.class);
ApplicationProperties.Security.Validation validation =
mock(ApplicationProperties.Security.Validation.class);
ApplicationProperties.Security.Validation.Trust trust =
mock(ApplicationProperties.Security.Validation.Trust.class);
ApplicationProperties.Security.Validation.Revocation revocation =
mock(ApplicationProperties.Security.Validation.Revocation.class);
when(applicationProperties.getSecurity()).thenReturn(security);
when(security.getValidation()).thenReturn(validation);
when(validation.getTrust()).thenReturn(trust);
when(validation.getRevocation()).thenReturn(revocation);
when(validation.isAllowAIA()).thenReturn(false);
when(trust.isServerAsAnchor()).thenReturn(false);
when(trust.isUseSystemTrust()).thenReturn(false);
when(trust.isUseMozillaBundle()).thenReturn(false);
when(trust.isUseAATL()).thenReturn(false);
when(trust.isUseEUTL()).thenReturn(false);
when(revocation.getMode()).thenReturn("none");
when(revocation.isHardFail()).thenReturn(false);
validationService = new CertificateValidationService(null, applicationProperties);
// Create mock certificates
validCertificate = mock(X509Certificate.class);
expiredCertificate = mock(X509Certificate.class);
// Set up behaviors for valid certificate
doNothing().when(validCertificate).checkValidity(); // No exception means valid
// Set up behaviors for valid certificate (both overloads)
doNothing().when(validCertificate).checkValidity();
doNothing().when(validCertificate).checkValidity(any(Date.class));
// Set up behaviors for expired certificate
// Set up behaviors for expired certificate (both overloads)
doThrow(new CertificateExpiredException("Certificate expired"))
.when(expiredCertificate)
.checkValidity();
doThrow(new CertificateExpiredException("Certificate expired"))
.when(expiredCertificate)
.checkValidity(any(Date.class));
}
@Test
void testIsRevoked_ValidCertificate() {
void testIsOutsideValidityPeriod_ValidCertificate() {
// When certificate is valid (not expired)
boolean result = validationService.isRevoked(validCertificate);
boolean result = validationService.isOutsideValidityPeriod(validCertificate, new Date());
// Then it should not be considered revoked
assertFalse(result, "Valid certificate should not be considered revoked");
// Then it should not be outside validity period
assertFalse(result, "Valid certificate should not be outside validity period");
}
@Test
void testIsRevoked_ExpiredCertificate() {
void testIsOutsideValidityPeriod_ExpiredCertificate() {
// When certificate is expired
boolean result = validationService.isRevoked(expiredCertificate);
boolean result = validationService.isOutsideValidityPeriod(expiredCertificate, new Date());
// Then it should be considered revoked
assertTrue(result, "Expired certificate should be considered revoked");
// Then it should be outside validity period
assertTrue(result, "Expired certificate should be outside validity period");
}
@Test
void testValidateTrustWithCustomCert_Match() {
// Create certificates with matching issuer and subject
X509Certificate issuingCert = mock(X509Certificate.class);
X509Certificate signedCert = mock(X509Certificate.class);
// Create X500Principal objects for issuer and subject
X500Principal issuerPrincipal = new X500Principal("CN=Test Issuer");
// Mock the issuer of the signed certificate to match the subject of the issuing certificate
when(signedCert.getIssuerX500Principal()).thenReturn(issuerPrincipal);
when(issuingCert.getSubjectX500Principal()).thenReturn(issuerPrincipal);
// When validating trust with custom cert
boolean result = validationService.validateTrustWithCustomCert(signedCert, issuingCert);
// Then validation should succeed
assertTrue(result, "Certificate with matching issuer and subject should validate");
}
@Test
void testValidateTrustWithCustomCert_NoMatch() {
// Create certificates with non-matching issuer and subject
X509Certificate issuingCert = mock(X509Certificate.class);
X509Certificate signedCert = mock(X509Certificate.class);
// Create X500Principal objects for issuer and subject
X500Principal issuerPrincipal = new X500Principal("CN=Test Issuer");
X500Principal differentPrincipal = new X500Principal("CN=Different Name");
// Mock the issuer of the signed certificate to NOT match the subject of the issuing
// certificate
when(signedCert.getIssuerX500Principal()).thenReturn(issuerPrincipal);
when(issuingCert.getSubjectX500Principal()).thenReturn(differentPrincipal);
// When validating trust with custom cert
boolean result = validationService.validateTrustWithCustomCert(signedCert, issuingCert);
// Then validation should fail
assertFalse(result, "Certificate with non-matching issuer and subject should not validate");
}
@Test
void testValidateCertificateChainWithCustomCert_Success() throws Exception {
// Setup mock certificates
X509Certificate signedCert = mock(X509Certificate.class);
X509Certificate signingCert = mock(X509Certificate.class);
PublicKey publicKey = mock(PublicKey.class);
when(signingCert.getPublicKey()).thenReturn(publicKey);
// When verifying the certificate with the signing cert's public key, don't throw exception
doNothing().when(signedCert).verify(Mockito.any());
// When validating certificate chain with custom cert
boolean result =
validationService.validateCertificateChainWithCustomCert(signedCert, signingCert);
// Then validation should succeed
assertTrue(result, "Certificate chain with proper signing should validate");
}
@Test
void testValidateCertificateChainWithCustomCert_Failure() throws Exception {
// Setup mock certificates
X509Certificate signedCert = mock(X509Certificate.class);
X509Certificate signingCert = mock(X509Certificate.class);
PublicKey publicKey = mock(PublicKey.class);
when(signingCert.getPublicKey()).thenReturn(publicKey);
// When verifying the certificate with the signing cert's public key, throw exception
// Need to use a specific exception that verify() can throw
doThrow(new java.security.SignatureException("Verification failed"))
.when(signedCert)
.verify(Mockito.any());
// When validating certificate chain with custom cert
boolean result =
validationService.validateCertificateChainWithCustomCert(signedCert, signingCert);
// Then validation should fail
assertFalse(result, "Certificate chain with failed signing should not validate");
}
// Note: Full integration tests for buildAndValidatePath() would require
// real certificate chains and trust anchors. These would be better as
// integration tests using actual signed PDFs from the test-signed-pdfs directory.
}

View File

@ -5,6 +5,7 @@ import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
import { useFileHandler } from '../../hooks/useFileHandler';
import { useFileState } from '../../contexts/FileContext';
import { useNavigationState, useNavigationActions } from '../../contexts/NavigationContext';
import { isBaseWorkbench } from '../../types/workbench';
import { useViewer } from '../../contexts/ViewerContext';
import './Workbench.css';
@ -33,7 +34,8 @@ export default function Workbench() {
sidebarsVisible,
setPreviewFile,
setPageEditorFunctions,
setSidebarsVisible
setSidebarsVisible,
customWorkbenchViews,
} = useToolWorkflow();
const { handleToolSelect } = useToolWorkflow();
@ -137,9 +139,14 @@ export default function Workbench() {
);
default:
return (
<LandingPage/>
);
if (!isBaseWorkbench(currentView)) {
const customView = customWorkbenchViews.find((view) => view.workbenchId === currentView && view.data != null);
if (customView) {
const CustomComponent = customView.component;
return <CustomComponent data={customView.data} />;
}
}
return <LandingPage />;
}
};
@ -157,6 +164,7 @@ export default function Workbench() {
<TopControls
currentView={currentView}
setCurrentView={setCurrentView}
customViews={customWorkbenchViews}
activeFiles={activeFiles.map(f => {
const stub = selectors.getStirlingFileStub(f.fileId);
return { fileId: f.fileId, name: f.name, versionNumber: stub?.versionNumber };

View File

@ -45,6 +45,7 @@ export default function RightRail() {
const disableForFullscreen = toolPanelMode === 'fullscreen' && leftPanelView === 'toolPicker';
const { workbench: currentView } = useNavigationState();
const isCustomWorkbench = typeof currentView === 'string' && currentView.startsWith('custom:');
const { selectors } = useFileState();
const { selectedFiles, selectedFileIds } = useFileSelection();
@ -186,7 +187,6 @@ export default function RightRail() {
<Divider className="right-rail-divider" />
</React.Fragment>
))}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
{renderWithTooltip(
<ActionIcon

View File

@ -5,7 +5,9 @@ import rainbowStyles from '../../styles/rainbow.module.css';
import VisibilityIcon from "@mui/icons-material/Visibility";
import EditNoteIcon from "@mui/icons-material/EditNote";
import FolderIcon from "@mui/icons-material/Folder";
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
import { WorkbenchType, isValidWorkbench } from '../../types/workbench';
import type { CustomWorkbenchViewInstance } from '../../contexts/ToolWorkflowContext';
import { FileDropdownMenu } from './FileDropdownMenu';
@ -25,7 +27,8 @@ const createViewOptions = (
switchingTo: WorkbenchType | null,
activeFiles: Array<{ fileId: string; name: string; versionNumber?: number }>,
currentFileIndex: number,
onFileSelect?: (index: number) => void
onFileSelect?: (index: number) => void,
customViews?: CustomWorkbenchViewInstance[]
) => {
const currentFile = activeFiles[currentFileIndex];
const isInViewer = currentView === 'viewer';
@ -95,17 +98,35 @@ const createViewOptions = (
value: "fileEditor",
};
// Build options array conditionally
return [
const baseOptions = [
viewerOption,
pageEditorOption,
fileEditorOption,
];
const customOptions = (customViews ?? [])
.filter((view) => view.data != null)
.map((view) => ({
label: (
<div style={viewOptionStyle as React.CSSProperties}>
{switchingTo === view.workbenchId ? (
<Loader size="xs" />
) : (
view.icon || <PictureAsPdfIcon fontSize="small" />
)}
<span>{view.label}</span>
</div>
),
value: view.workbenchId,
}));
return [...baseOptions, ...customOptions];
};
interface TopControlsProps {
currentView: WorkbenchType;
setCurrentView: (view: WorkbenchType) => void;
customViews?: CustomWorkbenchViewInstance[];
activeFiles?: Array<{ fileId: string; name: string; versionNumber?: number }>;
currentFileIndex?: number;
onFileSelect?: (index: number) => void;
@ -114,6 +135,7 @@ interface TopControlsProps {
const TopControls = ({
currentView,
setCurrentView,
customViews = [],
activeFiles = [],
currentFileIndex = 0,
onFileSelect,
@ -147,7 +169,7 @@ const TopControls = ({
<div className="absolute left-0 w-full top-0 z-[100] pointer-events-none">
<div className="flex justify-center mt-[0.5rem]">
<SegmentedControl
data={createViewOptions(currentView, switchingTo, activeFiles, currentFileIndex, onFileSelect)}
data={createViewOptions(currentView, switchingTo, activeFiles, currentFileIndex, onFileSelect, customViews)}
value={currentView}
onChange={handleViewChange}
color="blue"

View File

@ -17,6 +17,7 @@ const FavoriteStar: React.FC<FavoriteStarProps> = ({ isFavorite, onToggle, class
return (
<ActionIcon
component="span"
variant="subtle"
radius="xl"
size={size}
@ -24,6 +25,12 @@ const FavoriteStar: React.FC<FavoriteStarProps> = ({ isFavorite, onToggle, class
e.stopPropagation();
onToggle();
}}
onMouseDown={(e: React.MouseEvent) => {
e.stopPropagation();
}}
onKeyDown={(e: React.KeyboardEvent) => {
e.stopPropagation();
}}
className={className}
aria-label={isFavorite ? t('toolPanel.fullscreen.unfavorite', 'Remove from favourites') : t('toolPanel.fullscreen.favorite', 'Add to favourites')}
>

View File

@ -0,0 +1,145 @@
import React, { useMemo } from 'react';
import { Badge, Group, Stack, Text, Divider } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import type { SignatureValidationReportData } from '../../../types/validateSignature';
import './reportView/styles.css';
import ThumbnailPreview from './reportView/ThumbnailPreview';
import FileSummaryHeader from './reportView/FileSummaryHeader';
import SignatureSection from './reportView/SignatureSection';
interface ValidateSignatureReportViewProps {
data: SignatureValidationReportData;
}
const NoSignatureSection = ({ message, label }: { message: string; label: string }) => (
<Stack align="center" justify="center" gap="xs" style={{ minHeight: 360, width: '100%' }}>
<Badge color="gray" variant="light" size="lg" style={{ textTransform: 'uppercase' }}>
{label}
</Badge>
<Text size="sm" c="dimmed" style={{ textAlign: 'center' }}>
{message}
</Text>
</Stack>
);
const ValidateSignatureReportView: React.FC<ValidateSignatureReportViewProps> = ({ data }) => {
const { t } = useTranslation();
const noSignaturesLabel = t('validateSignature.noSignaturesShort', 'No signatures');
const pages = useMemo(() => {
const result: Array<{
entry: SignatureValidationReportData['entries'][number];
signatureIndex: number | null;
includeSummary: boolean;
}> = [];
for (const entry of data.entries) {
if (entry.signatures.length === 0 || entry.error) {
result.push({ entry, signatureIndex: null, includeSummary: true });
continue;
}
// First page includes summary and the first signature
result.push({ entry, signatureIndex: 0, includeSummary: true });
// Subsequent signatures each get their own page
for (let i = 1; i < entry.signatures.length; i += 1) {
result.push({ entry, signatureIndex: i, includeSummary: false });
}
}
return result;
}, [data.entries]);
return (
<div className="report-container">
<Stack gap="xl" align="center">
<Stack gap="xs" align="center">
<Badge size="lg" color="blue" variant="light">
{t('validateSignature.report.title', 'Signature Validation Report')}
</Badge>
<Text size="sm" c="dimmed">
{t('validateSignature.report.generatedAt', 'Generated')}{' '}
{new Date(data.generatedAt).toLocaleString()}
</Text>
</Stack>
{pages.map((pageDef, index) => (
<div className="simulated-page" key={`${pageDef.entry.fileId}-${index}`}>
<Stack gap="lg" style={{ flex: 1 }}>
{pageDef.includeSummary && (
<>
<Group align="flex-start" gap="lg">
<ThumbnailPreview
thumbnailUrl={pageDef.entry.thumbnailUrl}
fileName={pageDef.entry.fileName}
/>
<Stack gap="sm" style={{ flex: 1 }}>
<Group justify="space-between" align="flex-start">
<div>
<Text fw={700} size="xl" style={{ lineHeight: 1.1 }}>
{pageDef.entry.fileName}
</Text>
<Text size="sm" c="dimmed">
{t('validateSignature.report.entryLabel', 'Signature Summary')}
</Text>
</div>
<Badge color="gray" variant="light">
{t('validateSignature.report.page', 'Page')} {index + 1}
</Badge>
</Group>
<FileSummaryHeader
fileSize={pageDef.entry.fileSize}
createdAt={pageDef.entry.createdAtLabel ?? null}
totalSignatures={pageDef.entry.signatures.length}
lastSignatureDate={pageDef.entry.signatures[0]?.signatureDate}
/>
</Stack>
</Group>
<Divider />
</>
)}
{pageDef.entry.error ? (
<NoSignatureSection
message={pageDef.entry.error}
label={t('validateSignature.status.invalid', 'Invalid')}
/>
) : pageDef.entry.signatures.length === 0 ? (
<NoSignatureSection
message={t(
'validateSignature.noSignatures',
'No digital signatures found in this document'
)}
label={noSignaturesLabel}
/>
) : (
<Stack gap="xl">
{pageDef.signatureIndex === null ? null : (
<SignatureSection
signature={pageDef.entry.signatures[pageDef.signatureIndex]}
index={pageDef.signatureIndex}
/>
)}
</Stack>
)}
</Stack>
<Group justify="space-between" align="center" mt="auto" pt="md">
<Text size="xs" c="dimmed">
{t('validateSignature.report.footer', 'Validated via Stirling PDF')}
</Text>
<Text size="xs" c="dimmed">
{t('validateSignature.report.page', 'Page')} {index + 1} / {pages.length}
</Text>
</Group>
</div>
))}
</Stack>
</div>
);
};
export default ValidateSignatureReportView;

View File

@ -0,0 +1,223 @@
import { useCallback, useMemo, useState } from 'react';
import { Alert, Badge, Button, Divider, Group, Loader, Stack, Text, SegmentedControl } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import type { SignatureValidationReportEntry } from '../../../types/validateSignature';
import type { ValidateSignatureOperationHook } from '../../../hooks/tools/validateSignature/useValidateSignatureOperation';
import './reportView/styles.css';
import FitText from '../../shared/FitText';
import { SuggestedToolsSection } from '../shared/SuggestedToolsSection';
interface ValidateSignatureResultsProps {
operation: ValidateSignatureOperationHook;
results: SignatureValidationReportEntry[];
isLoading: boolean;
errorMessage: string | null;
reportAvailable?: boolean;
}
const useFileSummary = (results: SignatureValidationReportEntry[]) => {
return useMemo(() => {
if (results.length === 0) {
return { fileCount: 0, signatureCount: 0, fullyValidCount: 0 };
}
let signatureCount = 0;
let fullyValidCount = 0;
results.forEach((result) => {
signatureCount += result.signatures.length;
result.signatures.forEach((signature) => {
const isValid = signature.valid;
if (isValid) {
fullyValidCount += 1;
}
});
});
return {
fileCount: results.length,
signatureCount,
fullyValidCount,
};
}, [results]);
};
const findFileByExtension = (files: File[], extension: string) => {
return files.find((file) => file.name.toLowerCase().endsWith(extension));
};
const ValidateSignatureResults = ({
operation,
results,
isLoading,
errorMessage,
}: ValidateSignatureResultsProps) => {
const { t } = useTranslation();
const summary = useFileSummary(results);
const pdfFile = useMemo(() => findFileByExtension(operation.files, '.pdf'), [operation.files]);
const csvFile = useMemo(() => findFileByExtension(operation.files, '.csv'), [operation.files]);
const jsonFile = useMemo(() => findFileByExtension(operation.files, '.json'), [operation.files]);
const [selectedType, setSelectedType] = useState<'pdf' | 'csv' | 'json'>('pdf');
const selectedFile = useMemo(() => {
if (selectedType === 'pdf') return pdfFile ?? null;
if (selectedType === 'csv') return csvFile ?? null;
return jsonFile ?? null;
}, [selectedType, pdfFile, csvFile, jsonFile]);
const selectedDownloadLabel = useMemo(() => {
if (selectedType === 'pdf') return t('validateSignature.downloadPdf', 'Download PDF Report');
if (selectedType === 'csv') return t('validateSignature.downloadCsv', 'Download CSV');
return t('validateSignature.downloadJson', 'Download JSON');
}, [selectedType, t]);
const downloadTypeOptions = [
{ label: t('validateSignature.downloadType.pdf', 'PDF'), value: 'pdf' },
{ label: t('validateSignature.downloadType.csv', 'CSV'), value: 'csv' },
{ label: t('validateSignature.downloadType.json', 'JSON'), value: 'json' },
];
const handleDownload = useCallback((file: File) => {
const blobUrl = URL.createObjectURL(file);
const link = document.createElement('a');
link.href = blobUrl;
link.download = file.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(blobUrl);
}, []);
// Show the big loader only while we're still waiting for the first results.
if (isLoading && results.length === 0) {
return (
<Group justify="center" gap="sm" py="md">
<Loader size="sm" />
<Text>{t('validateSignature.processing', 'Validating signatures...')}</Text>
</Group>
);
}
if (!isLoading && results.length === 0) {
return (
<Alert color="gray" variant="light" title={t('validateSignature.results', 'Validation Results')}>
<Text size="sm">{t('validateSignature.noResults', 'Run the validation to generate a report.')}</Text>
</Alert>
);
}
return (
<Stack gap="md">
{/* While results are visible but background work continues (e.g. generating files),
show a light inline indicator without blocking downloads UI. */}
{isLoading && results.length > 0 && (
<Group justify="center" gap="xs">
<Loader size="xs" />
<Text size="sm">{t('validateSignature.finalizing', 'Preparing downloads...')}</Text>
</Group>
)}
{errorMessage && (
<Alert color="yellow" variant="light">
<Text size="sm">{errorMessage}</Text>
</Alert>
)}
<Group gap="sm">
<Badge color="blue" variant="light">
{t('validateSignature.report.filesEvaluated', '{{count}} files evaluated', {
count: summary.fileCount,
})}
</Badge>
<Badge color="teal" variant="light">
{t('validateSignature.report.signaturesFound', '{{count}} signatures detected', {
count: summary.signatureCount,
})}
</Badge>
{summary.signatureCount > 0 && (
<Badge color="green" variant="light">
{t('validateSignature.report.signaturesValid', '{{count}} fully valid', {
count: summary.fullyValidCount,
})}
</Badge>
)}
</Group>
<Stack gap="sm" style={{ maxHeight: '20rem', overflowY: 'auto' }}>
{results.map((result) => {
const hasError = Boolean(result.error);
const hasSignatures = result.signatures.length > 0;
const allValid = hasSignatures && result.signatures.every((signature) => signature.valid);
const badgeLabel = hasError
? t('validateSignature.status.invalid', 'Invalid')
: hasSignatures
? allValid
? t('validateSignature.status.valid', 'Valid')
: t('validateSignature.status.invalid', 'Invalid')
: t('validateSignature.noSignaturesShort', 'No signatures');
const badgeClass = hasError
? 'status-badge status-badge--invalid'
: hasSignatures
? allValid
? 'status-badge status-badge--valid'
: 'status-badge status-badge--warning'
: 'status-badge status-badge--neutral';
return (
<Stack key={result.fileId} gap={4} p="xs" style={{ borderLeft: '2px solid var(--mantine-color-gray-4)' }}>
<Group justify="space-between" align="flex-start">
<div style={{ flex: 1, minWidth: 0 }}>
<FitText text={result.fileName} lines={2} as="div" minimumFontScale={0.5} style={{ fontWeight: 600 }} />
</div>
<Badge className={badgeClass} variant="light">
{badgeLabel}
</Badge>
</Group>
<Text size="xs" c="dimmed">
{t('validateSignature.report.signatureCountLabel', '{{count}} signatures', {
count: result.signatures.length,
})}
</Text>
{!result.error && result.signatures.length === 0 && (
<Text size="xs" c="dimmed">
{t('validateSignature.noSignatures', 'No digital signatures found in this document')}
</Text>
)}
</Stack>
);
})}
</Stack>
<Divider />
<Stack gap="xs">
<Text size="sm" fw={600}>
{t('validateSignature.report.downloads', 'Downloads')}
</Text>
<SegmentedControl
value={selectedType}
onChange={(v) => setSelectedType(v as 'pdf' | 'csv' | 'json')}
data={downloadTypeOptions}
/>
<Button
color="blue"
onClick={() => selectedFile && handleDownload(selectedFile)}
disabled={!selectedFile}
fullWidth
>
{selectedDownloadLabel}
</Button>
{selectedType === 'pdf' && !pdfFile && (
<Text size="xs" c="dimmed">
{t('validateSignature.report.noPdf', 'PDF report will be available after a successful validation.')}
</Text>
)}
</Stack>
<SuggestedToolsSection />
</Stack>
);
};
export default ValidateSignatureResults;

View File

@ -0,0 +1,67 @@
import { Card, Group, Stack, Text, Button } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import FileUploadButton from '../../shared/FileUploadButton';
import { ValidateSignatureParameters } from '../../../hooks/tools/validateSignature/useValidateSignatureParameters';
interface ValidateSignatureSettingsProps {
parameters: ValidateSignatureParameters;
onParameterChange: <K extends keyof ValidateSignatureParameters>(parameter: K, value: ValidateSignatureParameters[K]) => void;
disabled?: boolean;
}
const ValidateSignatureSettings = ({
parameters,
onParameterChange,
disabled = false,
}: ValidateSignatureSettingsProps) => {
const { t } = useTranslation();
const certFile = parameters.certFile;
const handleCertFileChange = (file: File | null) => {
onParameterChange('certFile', file);
};
return (
<Card withBorder radius="md" padding="md">
<Stack gap="sm">
<div>
<Text fw={600}>{t('validateSignature.selectCustomCert', 'Custom Certificate File X.509 (Optional)')}</Text>
<Text size="sm" c="dimmed">
{t(
'validateSignature.settings.certHint',
'Upload a trusted X.509 certificate to validate against a custom trust source.'
)}
</Text>
</div>
<Group align="center" gap="sm">
<FileUploadButton
file={certFile ?? undefined}
onChange={handleCertFileChange}
accept=".cer,.crt,.pem,.der"
disabled={disabled}
variant="filled"
/>
{certFile && (
<Button
variant="subtle"
color="gray"
onClick={() => handleCertFileChange(null)}
disabled={disabled}
>
{t('sign.clear', 'Clear')}
</Button>
)}
</Group>
{certFile && (
<Text size="xs" c="dimmed">
{t('size', 'Size')}: {Math.round(certFile.size / 1024)} KB
</Text>
)}
</Stack>
</Card>
);
};
export default ValidateSignatureSettings;

View File

@ -0,0 +1,23 @@
import React from 'react';
import { Text } from '@mantine/core';
import './styles.css';
const FieldBlock = (label: string, value: React.ReactNode) => {
const displayValue =
value === null || value === undefined || value === '' ? '-' : value;
return (
<div className="field-container" key={label}>
<Text size="xs" fw={600} c="dimmed" tt="uppercase" style={{ letterSpacing: 0.6 }}>
{label}
</Text>
<div className="field-value">
<Text size="sm" fw={500} style={{ lineHeight: 1.35, whiteSpace: 'pre-wrap' }}>
{displayValue}
</Text>
</div>
</div>
);
};
export default FieldBlock;

View File

@ -0,0 +1,47 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import './styles.css';
import FieldBlock from './FieldBlock';
const formatDate = (value?: string | null) => {
if (!value) return '--';
const parsed = Date.parse(value);
if (!Number.isNaN(parsed)) {
return new Date(parsed).toLocaleString();
}
return value;
};
const formatFileSize = (bytes?: number | null) => {
if (bytes === undefined || bytes === null) return '--';
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
const size = bytes / Math.pow(1024, exponent);
return `${size.toFixed(exponent === 0 ? 0 : 1)} ${units[exponent]}`;
};
const FileSummaryHeader = ({
fileSize,
createdAt,
totalSignatures,
lastSignatureDate,
}: {
fileSize?: number | null;
createdAt?: string | null;
totalSignatures: number;
lastSignatureDate?: string | null;
}) => {
const { t } = useTranslation();
const infoBlocks = [
FieldBlock(t('files.size', 'File Size'), formatFileSize(fileSize ?? null)),
FieldBlock(t('files.created', 'Created'), createdAt || '-'),
FieldBlock(t('validateSignature.signatureDate', 'Signature Date'), formatDate(lastSignatureDate)),
FieldBlock(t('validateSignature.totalSignatures', 'Total Signatures'), totalSignatures.toString()),
];
return <div className="grid-container">{infoBlocks}</div>;
};
export default FileSummaryHeader;

View File

@ -0,0 +1,78 @@
import React from 'react';
import { Divider, Group, Stack, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import type { SignatureValidationSignature } from '../../../../types/validateSignature';
import SignatureStatusBadge from './SignatureStatusBadge';
import FieldBlock from './FieldBlock';
import './styles.css';
const formatDate = (value?: string | null) => {
if (!value) return '-';
const parsed = Date.parse(value);
if (!Number.isNaN(parsed)) {
return new Date(parsed).toLocaleString();
}
return value;
};
const SignatureSection = ({
signature,
index,
}: {
signature: SignatureValidationSignature;
index: number;
}) => {
const { t } = useTranslation();
const signatureFields = [
FieldBlock(t('validateSignature.signer', 'Signer'), signature.signerName || '-'),
FieldBlock(t('validateSignature.date', 'Date'), formatDate(signature.signatureDate)),
FieldBlock(t('validateSignature.reason', 'Reason'), signature.reason || '-'),
FieldBlock(t('validateSignature.location', 'Location'), signature.location || '-'),
];
const certificateFields = [
FieldBlock(t('validateSignature.cert.issuer', 'Issuer'), signature.issuerDN || '-'),
FieldBlock(t('validateSignature.cert.subject', 'Subject'), signature.subjectDN || '-'),
FieldBlock(t('validateSignature.cert.serialNumber', 'Serial Number'), signature.serialNumber || '-'),
FieldBlock(t('validateSignature.cert.validFrom', 'Valid From'), formatDate(signature.validFrom)),
FieldBlock(t('validateSignature.cert.validUntil', 'Valid Until'), formatDate(signature.validUntil)),
FieldBlock(t('validateSignature.cert.algorithm', 'Algorithm'), signature.signatureAlgorithm || '-'),
FieldBlock(
t('validateSignature.cert.keySize', 'Key Size'),
signature.keySize != null ? `${signature.keySize} ${t('validateSignature.cert.bits', 'bits')}` : '--'
),
FieldBlock(t('validateSignature.cert.version', 'Version'), signature.version || '-'),
FieldBlock(
t('validateSignature.cert.keyUsage', 'Key Usage'),
signature.keyUsages.length > 0 ? signature.keyUsages.join(', ') : '--'
),
FieldBlock(t('validateSignature.cert.selfSigned', 'Self-Signed'), signature.selfSigned ? t('yes', 'Yes') : t('no', 'No')),
];
return (
<Stack gap="md" key={signature.id}>
<Group justify="space-between" align="center">
<Group gap="sm">
<Text fw={700} size="lg">
{t('validateSignature.signature._value', 'Signature')} {index + 1}
</Text>
<SignatureStatusBadge signature={signature} />
</Group>
{signature.errorMessage && (
<Text c="red" size="sm">{signature.errorMessage}</Text>
)}
</Group>
<div className="grid-container">{signatureFields}</div>
<Divider my="sm" />
<Text fw={600} size="sm" c="dimmed" tt="uppercase" style={{ letterSpacing: 0.8 }}>
{t('validateSignature.cert.details', 'Certificate Details')}
</Text>
<div className="grid-container">{certificateFields}</div>
</Stack>
);
};
export default SignatureSection;

View File

@ -0,0 +1,40 @@
import React from 'react';
import { Badge, Popover, Text } from '@mantine/core';
import './styles.css';
import { useTranslation } from 'react-i18next';
import { computeSignatureStatus } from '../../../../hooks/tools/validateSignature/utils/signatureStatus';
import type { SignatureValidationSignature } from '../../../../types/validateSignature';
const SignatureStatusBadge = ({ signature }: { signature: SignatureValidationSignature }) => {
const { t } = useTranslation();
const status = computeSignatureStatus(signature, t);
const classMap = {
valid: 'status-badge status-badge--valid',
warning: 'status-badge status-badge--warning',
invalid: 'status-badge status-badge--invalid',
neutral: 'status-badge status-badge--neutral',
} as const;
return (
<Popover withinPortal position="bottom" withArrow shadow="md" disabled={status.details.length === 0}>
<Popover.Target>
<Badge className={classMap[status.kind]} variant="light" style={{ cursor: status.details.length ? 'pointer' : 'default' }}>
{status.label}
</Badge>
</Popover.Target>
{status.details.length > 0 && (
<Popover.Dropdown>
<Text size="sm" fw={600} mb={4}>{t('details', 'Details')}</Text>
{status.details.map((d, i) => (
<Text size="sm" key={i}>
- {d}
</Text>
))}
</Popover.Dropdown>
)}
</Popover>
);
};
export default SignatureStatusBadge;

View File

@ -0,0 +1,31 @@
import React from 'react';
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
import './styles.css';
const ThumbnailPreview = ({
thumbnailUrl,
fileName,
}: {
thumbnailUrl?: string | null;
fileName: string;
}) => {
if (thumbnailUrl) {
return (
<div className="thumbnail-container">
<img
src={thumbnailUrl}
alt={`${fileName} thumbnail`}
className="thumbnail-image"
/>
</div>
);
}
return (
<div className="thumbnail-placeholder">
<PictureAsPdfIcon fontSize="large" />
</div>
);
};
export default ThumbnailPreview;

View File

@ -0,0 +1,105 @@
.grid-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 12px;
}
.field-container {
display: flex;
flex-direction: column;
gap: 4px;
}
.field-value {
border: 1px solid rgb(var(--pdf-light-box-border));
border-radius: 8px;
padding: 0.65rem 0.75rem;
background-color: rgb(var(--pdf-light-box-bg));
min-height: 44px;
}
/* Status badge colors sourced from light palette tokens */
.status-badge {
border-radius: 9999px !important;
font-weight: 700 !important;
letter-spacing: 0.02em !important;
}
.status-badge--valid {
background-color: rgb(var(--pdf-light-status-valid-bg)) !important;
color: rgb(var(--pdf-light-status-valid-text)) !important;
}
.status-badge--warning {
background-color: rgb(var(--pdf-light-status-warning-bg)) !important;
color: rgb(var(--pdf-light-status-warning-text)) !important;
}
.status-badge--invalid {
background-color: rgb(var(--pdf-light-status-invalid-bg)) !important;
color: rgb(var(--pdf-light-status-invalid-text)) !important;
}
.status-badge--neutral {
background-color: rgb(var(--pdf-light-status-neutral-bg)) !important;
color: rgb(var(--pdf-light-status-neutral-text)) !important;
}
.simulated-page {
width: min(820px, 100%);
min-height: 1040px;
background-color: rgb(var(--pdf-light-simulated-page-bg)) !important;
box-shadow: 0 12px 32px rgba(var(--pdf-light-simulated-page-text), 0.12) !important;
border-radius: 12px !important;
padding: 48px 56px !important;
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
color: rgb(var(--pdf-light-simulated-page-text)) !important;
}
/* Container for the interactive report view */
.report-container {
width: 100%;
height: 100%;
/* Match Active Files/Page Editor background */
background: var(--bg-background) !important;
padding: 32px 24px 48px;
overflow-y: auto;
}
/* Keep field blocks stable colors across themes */
.field-value {
border: 1px solid rgb(var(--pdf-light-box-border)) !important;
background-color: rgb(var(--pdf-light-box-bg)) !important;
}
.field-container {
color: rgb(var(--pdf-light-simulated-page-text)) !important;
}
/* Thumbnail preview styles */
.thumbnail-container {
width: 140px;
height: 180px;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 6px 18px rgba(var(--pdf-light-simulated-page-text), 0.15);
flex-shrink: 0;
background-color: rgb(var(--pdf-light-simulated-page-text));
}
.thumbnail-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.thumbnail-placeholder {
width: 140px;
height: 180px;
border-radius: 12px;
border: 1px dashed rgba(var(--pdf-light-neutral), 0.6);
display: flex;
align-items: center;
justify-content: center;
color: rgb(var(--pdf-light-text-muted));
background: linear-gradient(145deg, var(--mantine-color-gray-1) 0%, var(--mantine-color-gray-0) 100%);
}

View File

@ -9,8 +9,8 @@ import { PageEditorFunctions } from '../types/pageEditor';
import { ToolRegistryEntry, ToolRegistry } from '../data/toolsTaxonomy';
import { useNavigationActions, useNavigationState } from './NavigationContext';
import { ToolId, isValidToolId } from '../types/toolId';
import { WorkbenchType, getDefaultWorkbench, isBaseWorkbench } from '../types/workbench';
import { useNavigationUrlSync } from '../hooks/useUrlSync';
import { getDefaultWorkbench } from '../types/workbench';
import { filterToolRegistryByQuery } from '../utils/toolSearch';
import { useToolHistory } from '../hooks/tools/useUserToolActivity';
import {
@ -25,6 +25,18 @@ import { usePreferences } from './PreferencesContext';
// Types and reducer/state moved to './toolWorkflow/state'
// Context value interface
export interface CustomWorkbenchViewRegistration {
id: string;
workbenchId: WorkbenchType;
label: string;
icon?: React.ReactNode;
component: React.ComponentType<{ data: any }>;
}
export interface CustomWorkbenchViewInstance extends CustomWorkbenchViewRegistration {
data: any;
}
interface ToolWorkflowContextValue extends ToolWorkflowState {
// Tool management (from hook)
selectedToolKey: ToolId | null;
@ -63,9 +75,21 @@ interface ToolWorkflowContextValue extends ToolWorkflowState {
favoriteTools: ToolId[];
toggleFavorite: (toolId: ToolId) => void;
isFavorite: (toolId: ToolId) => boolean;
customWorkbenchViews: CustomWorkbenchViewInstance[];
registerCustomWorkbenchView: (view: CustomWorkbenchViewRegistration) => void;
unregisterCustomWorkbenchView: (id: string) => void;
setCustomWorkbenchViewData: (id: string, data: any) => void;
clearCustomWorkbenchViewData: (id: string) => void;
}
const ToolWorkflowContext = createContext<ToolWorkflowContextValue | undefined>(undefined);
// Ensure a single context instance across HMR to avoid provider/consumer mismatches
const __GLOBAL_CONTEXT_KEY__ = '__ToolWorkflowContext__';
const existingContext = (globalThis as any)[__GLOBAL_CONTEXT_KEY__] as React.Context<ToolWorkflowContextValue | undefined> | undefined;
const ToolWorkflowContext = existingContext ?? createContext<ToolWorkflowContextValue | undefined>(undefined);
if (!existingContext) {
(globalThis as any)[__GLOBAL_CONTEXT_KEY__] = ToolWorkflowContext;
}
// Provider component
interface ToolWorkflowProviderProps {
@ -79,6 +103,9 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
// Store reset functions for tools
const [toolResetFunctions, setToolResetFunctions] = React.useState<Record<string, () => void>>({});
const [customViewRegistry, setCustomViewRegistry] = React.useState<Record<string, CustomWorkbenchViewRegistration>>({});
const [customViewData, setCustomViewData] = React.useState<Record<string, any>>({});
// Navigation actions and state are available since we're inside NavigationProvider
const { actions } = useNavigationActions();
const navigationState = useNavigationState();
@ -137,6 +164,73 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
dispatch({ type: 'SET_SEARCH_QUERY', payload: query });
}, []);
const registerCustomWorkbenchView = useCallback((view: CustomWorkbenchViewRegistration) => {
setCustomViewRegistry(prev => ({ ...prev, [view.id]: view }));
}, []);
const unregisterCustomWorkbenchView = useCallback((id: string) => {
let removedView: CustomWorkbenchViewRegistration | undefined;
setCustomViewRegistry(prev => {
const existing = prev[id];
if (!existing) {
return prev;
}
removedView = existing;
const updated = { ...prev };
delete updated[id];
return updated;
});
setCustomViewData(prev => {
if (!(id in prev)) {
return prev;
}
const updated = { ...prev };
delete updated[id];
return updated;
});
if (removedView && navigationState.workbench === removedView.workbenchId) {
actions.setWorkbench(getDefaultWorkbench());
}
}, [actions, navigationState.workbench]);
const setCustomWorkbenchViewData = useCallback((id: string, data: any) => {
setCustomViewData(prev => ({ ...prev, [id]: data }));
}, []);
const clearCustomWorkbenchViewData = useCallback((id: string) => {
setCustomViewData(prev => {
if (!(id in prev)) {
return prev;
}
const updated = { ...prev };
delete updated[id];
return updated;
});
}, []);
const customWorkbenchViews = useMemo<CustomWorkbenchViewInstance[]>(() => {
return Object.values(customViewRegistry).map(view => ({
...view,
data: Object.prototype.hasOwnProperty.call(customViewData, view.id) ? customViewData[view.id] : null,
}));
}, [customViewRegistry, customViewData]);
useEffect(() => {
if (isBaseWorkbench(navigationState.workbench)) {
return;
}
const currentCustomView = customWorkbenchViews.find(view => view.workbenchId === navigationState.workbench);
if (!currentCustomView || currentCustomView.data == null) {
actions.setWorkbench(getDefaultWorkbench());
}
}, [actions, customWorkbenchViews, navigationState.workbench]);
// Persisted via PreferencesContext; no direct localStorage writes needed here
// Keep tool panel mode in sync with user preference. This ensures the
// Config setting (Default tool picker mode) immediately affects the app
// and persists across reloads.
@ -165,11 +259,15 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
// Workflow actions (compound actions that coordinate multiple state changes)
const handleToolSelect = useCallback((toolId: ToolId) => {
// If we're currently on a custom workbench (e.g., Validate Signature report),
// selecting any tool should take the user back to the default file manager view.
const wasInCustomWorkbench = !isBaseWorkbench(navigationState.workbench);
// Handle read tool selection - should behave exactly like QuickAccessBar read button
if (toolId === 'read') {
setReaderMode(true);
actions.setSelectedTool('read');
actions.setWorkbench('viewer');
actions.setWorkbench(wasInCustomWorkbench ? getDefaultWorkbench() : 'viewer');
setSearchQuery('');
return;
}
@ -179,7 +277,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
setReaderMode(false);
setLeftPanelView('hidden');
actions.setSelectedTool('multiTool');
actions.setWorkbench('pageEditor');
actions.setWorkbench(wasInCustomWorkbench ? getDefaultWorkbench() : 'pageEditor');
setSearchQuery('');
return;
}
@ -190,7 +288,9 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
// Get the tool from registry to determine workbench
const tool = getSelectedTool(toolId);
if (tool && tool.workbench) {
if (wasInCustomWorkbench) {
actions.setWorkbench(getDefaultWorkbench());
} else if (tool && tool.workbench) {
actions.setWorkbench(tool.workbench);
} else {
actions.setWorkbench(getDefaultWorkbench());
@ -200,7 +300,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
setSearchQuery('');
setLeftPanelView('toolContent');
setReaderMode(false); // Disable read mode when selecting tools
}, [actions, getSelectedTool, setLeftPanelView, setReaderMode, setSearchQuery]);
}, [actions, getSelectedTool, navigationState.workbench, setLeftPanelView, setReaderMode, setSearchQuery]);
const handleBackToTools = useCallback(() => {
setLeftPanelView('toolPicker');
@ -269,6 +369,13 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
favoriteTools,
toggleFavorite,
isFavorite,
// Custom workbench views
customWorkbenchViews,
registerCustomWorkbenchView,
unregisterCustomWorkbenchView,
setCustomWorkbenchViewData,
clearCustomWorkbenchViewData,
}), [
state,
navigationState.selectedTool,
@ -293,6 +400,11 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
favoriteTools,
toggleFavorite,
isFavorite,
customWorkbenchViews,
registerCustomWorkbenchView,
unregisterCustomWorkbenchView,
setCustomWorkbenchViewData,
clearCustomWorkbenchViewData,
]);
return (

View File

@ -26,6 +26,7 @@ export type ToolWorkflowAction =
| { type: 'SET_SEARCH_QUERY'; payload: string }
| { type: 'RESET_UI_STATE' };
export const baseState: Omit<ToolWorkflowState, 'toolPanelMode'> = {
sidebarsVisible: true,
leftPanelView: 'toolPicker',

View File

@ -108,6 +108,7 @@ import RemovePagesSettings from "../components/tools/removePages/RemovePagesSett
import RemoveBlanksSettings from "../components/tools/removeBlanks/RemoveBlanksSettings";
import AddPageNumbersAutomationSettings from "../components/tools/addPageNumbers/AddPageNumbersAutomationSettings";
import OverlayPdfsSettings from "../components/tools/overlayPdfs/OverlayPdfsSettings";
import ValidateSignature from "../tools/ValidateSignature";
const showPlaceholderTools = true; // Show all tools; grey out unavailable ones in UI
@ -281,10 +282,12 @@ export function useFlatToolRegistry(): ToolRegistry {
validateSignature: {
icon: <LocalIcon icon="verified-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.validateSignature.title", "Validate PDF Signature"),
component: null,
component: ValidateSignature,
description: t("home.validateSignature.desc", "Verify digital signatures and certificates in PDF documents"),
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.VERIFICATION,
maxFiles: -1,
endpoints: ["validate-signature"],
synonyms: getSynonyms(t, "validateSignature"),
automationSettings: null
},

View File

@ -2,12 +2,9 @@ import { useTranslation } from 'react-i18next';
import { useToolOperation, ToolType } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { RemoveAnnotationsParameters, defaultParameters } from './useRemoveAnnotationsParameters';
import { PDFDocument, PDFName, PDFRef, PDFDict } from 'pdf-lib';
// Client-side PDF processing using PDF-lib
const removeAnnotationsProcessor = async (_parameters: RemoveAnnotationsParameters, files: File[]): Promise<File[]> => {
// Dynamic import of PDF-lib for client-side processing
const { PDFDocument, PDFName, PDFRef, PDFDict } = await import('pdf-lib');
const processedFiles: File[] = [];
for (const file of files) {

View File

@ -0,0 +1,68 @@
import { PDFFont, PDFPage, rgb } from 'pdf-lib';
import { wrapText } from '../utils/pdfText';
import { colorPalette } from '../utils/pdfPalette';
interface DrawCenteredMessageOptions {
page: PDFPage;
font: PDFFont;
fontBold: PDFFont;
text: string;
description: string;
marginX: number;
contentWidth: number;
cursorY: number;
badgeColor: ReturnType<typeof rgb>;
}
export const drawCenteredMessage = ({
page,
font,
fontBold,
text,
description,
marginX,
contentWidth,
cursorY,
badgeColor,
}: DrawCenteredMessageOptions): number => {
const badgeFontSize = 10;
const badgePaddingX = 14;
const badgePaddingY = 6;
const badgeWidth = font.widthOfTextAtSize(text, badgeFontSize) + badgePaddingX * 2;
const badgeHeight = badgeFontSize + badgePaddingY * 2;
const badgeX = marginX + (contentWidth - badgeWidth) / 2;
page.drawRectangle({
x: badgeX,
y: cursorY - badgeHeight,
width: badgeWidth,
height: badgeHeight,
color: badgeColor,
});
page.drawText(text, {
x: badgeX + badgePaddingX,
y: cursorY - badgePaddingY - badgeFontSize + 2,
size: badgeFontSize,
font: fontBold,
color: rgb(1, 1, 1),
});
let nextCursor = cursorY - 32;
const lines = wrapText(description, font, 11, contentWidth * 0.75);
lines.forEach((line) => {
const lineWidth = font.widthOfTextAtSize(line, 11);
const lineX = marginX + (contentWidth - lineWidth) / 2;
page.drawText(line, {
x: lineX,
y: nextCursor,
size: 11,
font,
color: colorPalette.textPrimary,
});
nextCursor -= 18;
});
return nextCursor - 8;
};

View File

@ -0,0 +1,70 @@
import { PDFFont, PDFPage } from 'pdf-lib';
import { wrapText } from '../utils/pdfText';
import { colorPalette } from '../utils/pdfPalette';
interface FieldBoxOptions {
page: PDFPage;
font: PDFFont;
fontBold: PDFFont;
x: number;
top: number;
width: number;
label: string;
value: string;
}
export const drawFieldBox = ({
page,
font,
fontBold,
x,
top,
width,
label,
value,
}: FieldBoxOptions): number => {
const labelFontSize = 8;
const valueFontSize = 11;
const valueLineHeight = valueFontSize * 1.25;
const boxPadding = 6;
page.drawText(label.toUpperCase(), {
x,
y: top - labelFontSize,
size: labelFontSize,
font: fontBold,
color: colorPalette.textMuted,
});
const boxTop = top - labelFontSize - 6;
const rawValue = value && value.trim().length > 0 ? value : '--';
const lines = wrapText(rawValue, font, valueFontSize, width - boxPadding * 2);
const boxHeight = Math.max(valueLineHeight, lines.length * valueLineHeight) + boxPadding * 2;
page.drawRectangle({
x,
y: boxTop - boxHeight,
width,
height: boxHeight,
color: colorPalette.boxBackground,
borderColor: colorPalette.boxBorder,
});
let textY = boxTop - boxPadding - valueFontSize;
lines.forEach((line) => {
const lineWidth = font.widthOfTextAtSize(line, valueFontSize);
const available = width - boxPadding * 2;
const centeredX = x + boxPadding + Math.max(0, (available - lineWidth) / 2);
page.drawText(line, {
x: centeredX,
y: textY,
size: valueFontSize,
font,
color: colorPalette.textPrimary,
});
textY -= valueLineHeight;
});
return labelFontSize + 6 + boxHeight + 6;
};

View File

@ -0,0 +1,173 @@
import type { TFunction } from 'i18next';
import { PDFFont, PDFPage } from 'pdf-lib';
import { SignatureValidationSignature } from '../../../../types/validateSignature';
import { drawFieldBox } from './FieldBoxSection';
import { drawStatusBadge } from './StatusBadgeSection';
import { computeSignatureStatus, statusKindToPdfColor } from '../utils/signatureStatus';
import { formatDate } from '../utils/pdfText';
import { colorPalette } from '../utils/pdfPalette';
interface DrawSignatureSectionOptions {
page: PDFPage;
cursorY: number;
signature: SignatureValidationSignature;
index: number;
marginX: number;
contentWidth: number;
columnGap: number;
font: PDFFont;
fontBold: PDFFont;
t: TFunction<'translation'>;
}
export const drawSignatureSection = ({
page,
cursorY,
signature,
index,
marginX,
contentWidth,
columnGap,
font,
fontBold,
t,
}: DrawSignatureSectionOptions): number => {
const columnWidth = (contentWidth - columnGap) / 2;
const heading = `${t('validateSignature.signature._value', 'Signature')} ${index + 1}`;
page.drawText(heading, {
x: marginX,
y: cursorY,
size: 14,
font: fontBold,
color: colorPalette.textPrimary,
});
const status = computeSignatureStatus(signature, t);
const statusColor = statusKindToPdfColor(status.kind);
const headingWidth = fontBold.widthOfTextAtSize(heading, 14);
drawStatusBadge({
page,
font,
fontBold,
text: status.label,
x: marginX + headingWidth + 16,
y: cursorY + 14,
color: statusColor,
});
let nextY = cursorY - 20;
const signatureFields = [
{ label: t('validateSignature.signer', 'Signer'), value: signature.signerName || '-' },
{ label: t('validateSignature.date', 'Date'), value: formatDate(signature.signatureDate) },
{ label: t('validateSignature.reason', 'Reason'), value: signature.reason || '-' },
{ label: t('validateSignature.location', 'Location'), value: signature.location || '-' },
];
for (let i = 0; i < signatureFields.length; i += 2) {
const leftField = signatureFields[i];
const rightField = signatureFields[i + 1];
const leftHeight = drawFieldBox({
page,
font,
fontBold,
x: marginX,
top: nextY,
width: columnWidth,
label: leftField.label,
value: leftField.value,
});
let rowHeight = leftHeight;
if (rightField) {
const rightHeight = drawFieldBox({
page,
font,
fontBold,
x: marginX + columnWidth + columnGap,
top: nextY,
width: columnWidth,
label: rightField.label,
value: rightField.value,
});
rowHeight = Math.max(leftHeight, rightHeight);
}
nextY -= rowHeight + 8;
}
nextY -= 6;
page.drawLine({
start: { x: marginX, y: nextY },
end: { x: marginX + contentWidth, y: nextY },
thickness: 1,
color: colorPalette.boxBorder,
});
nextY -= 20;
const certificateFields = [
{ label: t('validateSignature.cert.issuer', 'Issuer'), value: signature.issuerDN || '-' },
{ label: t('validateSignature.cert.subject', 'Subject'), value: signature.subjectDN || '-' },
{ label: t('validateSignature.cert.serialNumber', 'Serial Number'), value: signature.serialNumber || '-' },
{ label: t('validateSignature.cert.algorithm', 'Algorithm'), value: signature.signatureAlgorithm || '-' },
{ label: t('validateSignature.cert.validFrom', 'Valid From'), value: formatDate(signature.validFrom) },
{ label: t('validateSignature.cert.validUntil', 'Valid Until'), value: formatDate(signature.validUntil) },
{
label: t('validateSignature.cert.keySize', 'Key Size'),
value:
signature.keySize != null
? `${signature.keySize} ${t('validateSignature.cert.bits', 'bits')}`
: '--',
},
{ label: t('validateSignature.cert.version', 'Version'), value: signature.version || '-' },
{
label: t('validateSignature.cert.keyUsage', 'Key Usage'),
value:
signature.keyUsages && signature.keyUsages.length > 0
? signature.keyUsages.join(', ')
: '--',
},
{
label: t('validateSignature.cert.selfSigned', 'Self-Signed'),
value: signature.selfSigned ? t('yes', 'Yes') : t('no', 'No'),
},
];
for (let i = 0; i < certificateFields.length; i += 2) {
const leftField = certificateFields[i];
const rightField = certificateFields[i + 1];
const leftHeight = drawFieldBox({
page,
font,
fontBold,
x: marginX,
top: nextY,
width: columnWidth,
label: leftField.label,
value: leftField.value,
});
let rowHeight = leftHeight;
if (rightField) {
const rightHeight = drawFieldBox({
page,
font,
fontBold,
x: marginX + columnWidth + columnGap,
top: nextY,
width: columnWidth,
label: rightField.label,
value: rightField.value,
});
rowHeight = Math.max(leftHeight, rightHeight);
}
nextY -= rowHeight + 8;
}
return nextY - 12;
};

View File

@ -0,0 +1,38 @@
import { PDFFont, PDFPage, rgb } from 'pdf-lib';
interface StatusBadgeOptions {
page: PDFPage;
font: PDFFont;
fontBold: PDFFont;
text: string;
x: number;
y: number;
color: ReturnType<typeof rgb>;
}
export const drawStatusBadge = ({ page, font, fontBold, text, x, y, color }: StatusBadgeOptions): number => {
const paddingX = 14;
const paddingY = 6;
const fontSize = 10;
const textWidth = font.widthOfTextAtSize(text, fontSize);
const width = textWidth + paddingX * 2;
const height = fontSize + paddingY * 2;
page.drawRectangle({
x,
y: y - height,
width,
height,
color,
});
page.drawText(text, {
x: x + paddingX,
y: y - paddingY - fontSize + 2,
size: fontSize,
font: fontBold,
color: rgb(1, 1, 1),
});
return width;
};

View File

@ -0,0 +1,145 @@
import type { TFunction } from 'i18next';
import { PDFFont, PDFImage, PDFPage } from 'pdf-lib';
import { SignatureValidationReportEntry } from '../../../../types/validateSignature';
import { drawFieldBox } from './FieldBoxSection';
import { drawThumbnailImage, drawThumbnailPlaceholder } from './ThumbnailSection';
import { colorPalette } from '../utils/pdfPalette';
import { formatFileSize } from '../utils/pdfText';
interface DrawSummarySectionOptions {
page: PDFPage;
cursorY: number;
entry: SignatureValidationReportEntry;
font: PDFFont;
fontBold: PDFFont;
marginX: number;
contentWidth: number;
columnGap: number;
statusText: string;
statusColor: (typeof colorPalette)['success'];
loadThumbnail: (url: string) => Promise<{ image: PDFImage } | null>;
t: TFunction<'translation'>;
}
export const drawSummarySection = async ({
page,
cursorY,
entry,
font,
fontBold,
marginX,
contentWidth,
columnGap,
loadThumbnail,
t,
}: DrawSummarySectionOptions): Promise<number> => {
const thumbnailWidth = 140;
const thumbnailHeight = 180;
const summaryX = marginX + thumbnailWidth + 24;
const summaryWidth = contentWidth - (thumbnailWidth + 24);
const summaryColumnWidth = (summaryWidth - columnGap) / 2;
const rowSpacing = 8;
const summaryTop = cursorY;
const titleFontSize = 22;
const subtitleFontSize = 11;
const latestSignatureTimestamp = entry.signatures
.map((sig) => (sig.signatureDate ? Date.parse(sig.signatureDate) : NaN))
.filter((value) => !Number.isNaN(value));
const latestSignatureLabel = latestSignatureTimestamp.length
? new Date(Math.max(...latestSignatureTimestamp)).toLocaleString()
: '--';
const titleBaseline = summaryTop - 12 - titleFontSize;
page.drawText(entry.fileName, {
x: summaryX,
y: titleBaseline,
size: titleFontSize,
font: fontBold,
color: colorPalette.textPrimary,
});
const subtitle = t('validateSignature.report.shortTitle', 'Signature Summary');
const subtitleBaseline = titleBaseline - subtitleFontSize - 6;
page.drawText(subtitle, {
x: summaryX,
y: subtitleBaseline,
size: subtitleFontSize,
font,
color: colorPalette.textMuted,
});
const summaryRows: Array<
Array<{
label: string;
value: string;
}>
> = [
[
{ label: t('validateSignature.report.fields.fileSize', 'File Size'), value: formatFileSize(entry.fileSize) },
{ label: t('validateSignature.report.fields.created', 'Created'), value: entry.createdAtLabel ?? '--' },
],
[
{ label: t('validateSignature.report.fields.signatureDate', 'Signature Date'), value: latestSignatureLabel },
{ label: t('validateSignature.report.fields.signatureCount', 'Total Signatures'), value: entry.signatures.length.toString() },
],
];
let rowTop = subtitleBaseline - subtitleFontSize - 18;
summaryRows.forEach((fields, rowIndex) => {
let rowHeight = 0;
const singleColumn = fields.length === 1;
fields.forEach((field, index) => {
const fieldWidth = singleColumn ? summaryWidth : summaryColumnWidth;
const x = singleColumn ? summaryX : summaryX + index * (summaryColumnWidth + columnGap);
const fieldHeight = drawFieldBox({
page,
font,
fontBold,
x,
top: rowTop,
width: fieldWidth,
label: field.label,
value: field.value,
});
rowHeight = Math.max(rowHeight, fieldHeight);
});
rowTop -= rowHeight;
if (rowIndex < summaryRows.length - 1) {
rowTop -= rowSpacing;
}
});
const rightContentHeight = summaryTop - rowTop;
const thumbX = marginX;
const thumbTop = summaryTop;
if (entry.thumbnailUrl) {
const thumbnail = await loadThumbnail(entry.thumbnailUrl);
if (thumbnail?.image) {
page.drawRectangle({
x: thumbX,
y: thumbTop - thumbnailHeight,
width: thumbnailWidth,
height: thumbnailHeight,
color: colorPalette.boxBackground,
borderColor: colorPalette.boxBorder,
borderWidth: 1,
});
drawThumbnailImage(page, thumbnail.image, thumbX, thumbTop, thumbnailWidth, thumbnailHeight);
} else {
drawThumbnailPlaceholder(page, fontBold, thumbX, thumbTop, thumbnailWidth, thumbnailHeight);
}
} else {
drawThumbnailPlaceholder(page, fontBold, thumbX, thumbTop, thumbnailWidth, thumbnailHeight);
}
const summarySectionHeight = Math.max(thumbnailHeight, rightContentHeight);
return summaryTop - summarySectionHeight - 32;
};

View File

@ -0,0 +1,55 @@
import { PDFFont, PDFPage, PDFImage } from 'pdf-lib';
import { colorPalette } from '../utils/pdfPalette';
export const drawThumbnailPlaceholder = (
page: PDFPage,
fontBold: PDFFont,
x: number,
top: number,
width: number,
height: number
) => {
page.drawRectangle({
x,
y: top - height,
width,
height,
color: colorPalette.boxBackground,
borderColor: colorPalette.boxBorder,
borderWidth: 1,
});
const label = 'PDF';
const labelSize = 22;
const labelWidth = fontBold.widthOfTextAtSize(label, labelSize);
const labelX = x + (width - labelWidth) / 2;
const labelY = top - height / 2 - labelSize / 2;
page.drawText(label, {
x: labelX,
y: labelY,
size: labelSize,
font: fontBold,
color: colorPalette.textMuted,
});
};
export const drawThumbnailImage = (
page: PDFPage,
image: PDFImage,
x: number,
top: number,
width: number,
height: number
) => {
const scaled = image.scaleToFit(width - 16, height - 16);
const offsetX = x + (width - scaled.width) / 2;
const offsetY = top - (height - scaled.height) / 2 - scaled.height;
page.drawImage(image, {
x: offsetX,
y: offsetY,
width: scaled.width,
height: scaled.height,
});
};

View File

@ -0,0 +1,139 @@
import { PDFDocument, PDFPage, StandardFonts } from 'pdf-lib';
import type { TFunction } from 'i18next';
import { SignatureValidationReportEntry } from '../../../types/validateSignature';
import { REPORT_PDF_FILENAME } from './utils/signatureUtils';
import { colorPalette } from './utils/pdfPalette';
import { startReportPage, createThumbnailLoader } from './utils/pdfPageHelpers';
import { deriveEntryStatus } from './utils/reportStatus';
import { drawCenteredMessage } from './outputtedPDFSections/CenteredMessageSection';
import { drawSummarySection } from './outputtedPDFSections/SummarySection';
import { drawSignatureSection } from './outputtedPDFSections/SignatureSection';
const PAGE_WIDTH = 612;
const PAGE_HEIGHT = 792;
const MARGIN_X = 52;
const MARGIN_Y = 22;
const CONTENT_WIDTH = PAGE_WIDTH - MARGIN_X * 2;
const COLUMN_GAP = 18;
const drawDivider = (page: PDFPage, marginX: number, contentWidth: number, y: number) => {
page.drawLine({
start: { x: marginX, y },
end: { x: marginX + contentWidth, y },
thickness: 1,
color: colorPalette.boxBorder,
});
};
export const createReportPdf = async (
entries: SignatureValidationReportEntry[],
t: TFunction<'translation'>
): Promise<File> => {
const doc = await PDFDocument.create();
const font = await doc.embedFont(StandardFonts.Helvetica);
const fontBold = await doc.embedFont(StandardFonts.HelveticaBold);
const loadThumbnail = createThumbnailLoader(doc);
for (const entry of entries) {
const { text: statusText, color: statusColor } = deriveEntryStatus(entry, t);
let { page, cursorY } = startReportPage({
doc,
font,
fontBold,
marginX: MARGIN_X,
marginY: MARGIN_Y,
contentWidth: CONTENT_WIDTH,
pageWidth: PAGE_WIDTH,
pageHeight: PAGE_HEIGHT,
title: entry.fileName,
isContinuation: false,
t,
});
cursorY = await drawSummarySection({
page,
cursorY,
entry,
font,
fontBold,
marginX: MARGIN_X,
contentWidth: CONTENT_WIDTH,
columnGap: COLUMN_GAP,
statusText,
statusColor,
loadThumbnail,
t,
});
cursorY -= 12;
drawDivider(page, MARGIN_X, CONTENT_WIDTH, cursorY);
cursorY -= 16;
if (entry.error) {
cursorY = drawCenteredMessage({
page,
font,
fontBold,
text: t('validateSignature.status.invalid', 'Invalid'),
description: entry.error,
marginX: MARGIN_X,
contentWidth: CONTENT_WIDTH,
cursorY,
badgeColor: colorPalette.danger,
});
continue;
}
if (entry.signatures.length === 0) {
cursorY = drawCenteredMessage({
page,
font,
fontBold,
text: t('validateSignature.noSignaturesShort', 'No signatures'),
description: t('validateSignature.noSignatures', 'No digital signatures found in this document'),
marginX: MARGIN_X,
contentWidth: CONTENT_WIDTH,
cursorY,
badgeColor: colorPalette.neutral,
});
continue;
}
for (let i = 0; i < entry.signatures.length; i += 1) {
// After the first signature, start a new page per signature
if (i > 0) {
({ page, cursorY } = startReportPage({
doc,
font,
fontBold,
marginX: MARGIN_X,
marginY: MARGIN_Y,
contentWidth: CONTENT_WIDTH,
pageWidth: PAGE_WIDTH,
pageHeight: PAGE_HEIGHT,
title: entry.fileName,
isContinuation: true,
t,
}));
}
cursorY = drawSignatureSection({
page,
cursorY,
signature: entry.signatures[i],
index: i,
marginX: MARGIN_X,
contentWidth: CONTENT_WIDTH,
columnGap: COLUMN_GAP,
font,
fontBold,
t,
});
}
}
const pdfBytes = await doc.save();
const copy = pdfBytes.slice();
return new File([copy.buffer], REPORT_PDF_FILENAME, { type: 'application/pdf' });
};

View File

@ -0,0 +1,231 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import apiClient from '../../../services/apiClient';
import { useFileContext } from '../../../contexts/file/fileHooks';
import { ToolOperationHook } from '../shared/useToolOperation';
import type { StirlingFile } from '../../../types/fileContext';
import { extractErrorMessage } from '../../../utils/toolErrorHandler';
import {
SignatureValidationBackendResult,
SignatureValidationFileResult,
SignatureValidationReportEntry,
} from '../../../types/validateSignature';
import { ValidateSignatureParameters } from './useValidateSignatureParameters';
import { buildReportEntries } from './utils/signatureReportBuilder';
import { createReportPdf } from './signatureReportPdf';
import { createCsvFile as buildCsvFile } from './utils/signatureCsv';
import { normalizeBackendResult, RESULT_JSON_FILENAME } from './utils/signatureUtils';
export interface ValidateSignatureOperationHook extends ToolOperationHook<ValidateSignatureParameters> {
results: SignatureValidationReportEntry[];
}
export const useValidateSignatureOperation = (): ValidateSignatureOperationHook => {
const { t } = useTranslation();
const { selectors } = useFileContext();
const [isLoading, setIsLoading] = useState(false);
const [status, setStatus] = useState('');
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [files, setFiles] = useState<File[]>([]);
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
const [downloadFilename, setDownloadFilename] = useState('');
const [results, setResults] = useState<SignatureValidationReportEntry[]>([]);
const cancelRequested = useRef(false);
const previousUrl = useRef<string | null>(null);
const cleanupDownloadUrl = useCallback(() => {
if (previousUrl.current) {
URL.revokeObjectURL(previousUrl.current);
previousUrl.current = null;
}
}, []);
const resetResults = useCallback(() => {
cancelRequested.current = false;
setResults([]);
setFiles([]);
cleanupDownloadUrl();
setDownloadUrl(null);
setDownloadFilename('');
setStatus('');
setErrorMessage(null);
}, [cleanupDownloadUrl]);
const clearError = useCallback(() => {
setErrorMessage(null);
}, []);
const executeOperation = useCallback(
async (params: ValidateSignatureParameters, selectedFiles: StirlingFile[]) => {
if (selectedFiles.length === 0) {
setErrorMessage(t('noFileSelected', 'No files selected'));
return;
}
cancelRequested.current = false;
setIsLoading(true);
setStatus(t('validateSignature.processing', 'Validating signatures...'));
setErrorMessage(null);
setResults([]);
setFiles([]);
cleanupDownloadUrl();
setDownloadUrl(null);
setDownloadFilename('');
try {
const aggregated: SignatureValidationFileResult[] = [];
for (const file of selectedFiles) {
if (cancelRequested.current) {
break;
}
const formData = new FormData();
formData.append('fileInput', file);
if (params.certFile) {
formData.append('certFile', params.certFile);
}
try {
const response = await apiClient.post('/api/v1/security/validate-signature', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
const data = Array.isArray(response.data)
? (response.data as SignatureValidationBackendResult[])
: [];
const signatures = data.map((item, index) => normalizeBackendResult(item, file, index));
aggregated.push({
fileId: file.fileId,
fileName: file.name,
signatures,
error: null,
fileSize: file.size ?? null,
lastModified: file.lastModified ?? null,
});
} catch (error) {
aggregated.push({
fileId: file.fileId,
fileName: file.name,
signatures: [],
error: extractErrorMessage(error),
fileSize: file.size ?? null,
lastModified: file.lastModified ?? null,
});
}
}
if (!cancelRequested.current) {
const summaryTimestamp = Date.now();
const enrichedEntries = buildReportEntries({
results: aggregated,
selectors,
generatedAt: summaryTimestamp,
t,
});
setResults(enrichedEntries);
if (enrichedEntries.length > 0) {
const json = JSON.stringify(enrichedEntries, null, 2);
const resultFile = new File([json], RESULT_JSON_FILENAME, { type: 'application/json' });
const csvFile = buildCsvFile(enrichedEntries);
setFiles([resultFile, csvFile]);
(async () => {
try {
const pdfFile = await createReportPdf(enrichedEntries, t);
setFiles((prev) => [pdfFile, ...prev.filter((f) => !f.name.toLowerCase().endsWith('.pdf'))]);
setDownloadFilename(pdfFile.name);
cleanupDownloadUrl();
const blobUrl = URL.createObjectURL(pdfFile);
previousUrl.current = blobUrl;
setDownloadUrl(blobUrl);
} catch (err) {
console.warn('[validateSignature] PDF report generation failed', err);
setErrorMessage((prev) =>
prev ??
t(
'validateSignature.error.reportGeneration',
'Could not generate the PDF report. JSON and CSV are available.'
)
);
}
})();
}
const anyError = aggregated.some((item) => item.error);
const anySuccess = aggregated.some((item) => item.signatures.length > 0);
if (anyError && !anySuccess) {
setErrorMessage(t('validateSignature.error.allFailed', 'Unable to validate the selected files.'));
} else if (anyError) {
setErrorMessage(t('validateSignature.error.partial', 'Some files could not be validated.'));
}
setStatus(t('validateSignature.status.complete', 'Validation complete'));
}
} catch (e) {
console.error('[validateSignature] unexpected failure', e);
setErrorMessage(t('validateSignature.error.unexpected', 'Unexpected error during validation.'));
} finally {
setIsLoading(false);
}
},
[cleanupDownloadUrl, selectors, t]
);
const cancelOperation = useCallback(() => {
if (isLoading) {
cancelRequested.current = true;
setIsLoading(false);
setStatus(t('operationCancelled', 'Operation cancelled'));
}
}, [isLoading, t]);
const undoOperation = useCallback(async () => {
resetResults();
}, [resetResults]);
useEffect(() => {
return () => {
cleanupDownloadUrl();
};
}, [cleanupDownloadUrl]);
return useMemo<ValidateSignatureOperationHook>(
() => ({
files,
thumbnails: [],
isGeneratingThumbnails: false,
downloadUrl,
downloadFilename,
isLoading,
status,
errorMessage,
progress: null,
executeOperation,
resetResults,
clearError,
cancelOperation,
undoOperation,
results,
}),
[
cancelOperation,
clearError,
downloadFilename,
downloadUrl,
errorMessage,
executeOperation,
files,
isLoading,
resetResults,
results,
status,
]
);
};

View File

@ -0,0 +1,18 @@
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
export interface ValidateSignatureParameters {
certFile: File | null;
}
export const defaultParameters: ValidateSignatureParameters = {
certFile: null,
};
export type ValidateSignatureParametersHook = BaseParametersHook<ValidateSignatureParameters>;
export const useValidateSignatureParameters = (): ValidateSignatureParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: 'validate-signature',
});
};

View File

@ -0,0 +1,101 @@
import { PDFDocument, PDFFont, PDFImage } from 'pdf-lib';
import type { TFunction } from 'i18next';
import { colorPalette } from './pdfPalette';
interface StartPageParams {
doc: PDFDocument;
font: PDFFont;
fontBold: PDFFont;
marginX: number;
marginY: number;
contentWidth: number;
pageWidth: number;
pageHeight: number;
title: string;
isContinuation: boolean;
t: TFunction<'translation'>;
}
export const startReportPage = ({
doc,
font,
fontBold,
marginX,
marginY,
pageWidth,
pageHeight,
title,
isContinuation,
t,
}: StartPageParams) => {
const page = doc.addPage([pageWidth, pageHeight]);
let cursorY = pageHeight - marginY;
if (isContinuation) {
const heading = `${title} - ${t('validateSignature.report.continued', 'Continued')}`;
page.drawText(heading, {
x: marginX,
y: cursorY - 18,
size: 12,
font: fontBold,
color: colorPalette.textMuted,
});
cursorY -= 36;
}
const pageNumber = doc.getPageCount();
page.drawText(`${t('validateSignature.report.page', 'Page')} ${pageNumber}`, {
x: pageWidth - marginX - 80,
y: marginY / 2,
size: 9,
font,
color: colorPalette.textMuted,
});
page.drawText(t('validateSignature.report.footer', 'Validated via Stirling PDF'), {
x: marginX,
y: marginY / 2,
size: 9,
font,
color: colorPalette.textMuted,
});
return { page, cursorY };
};
export const createThumbnailLoader = (doc: PDFDocument) => {
const cache = new Map<string, { image: PDFImage } | null>();
return async (url: string) => {
if (cache.has(url)) {
return cache.get(url) ?? null;
}
try {
const response = await fetch(url);
const bytes = new Uint8Array(await response.arrayBuffer());
const contentType = response.headers.get('content-type') || '';
let image: PDFImage;
if (contentType.includes('png')) {
image = await doc.embedPng(bytes);
} else if (contentType.includes('jpeg') || contentType.includes('jpg')) {
image = await doc.embedJpg(bytes);
} else {
try {
image = await doc.embedPng(bytes);
} catch {
image = await doc.embedJpg(bytes);
}
}
const result = { image };
cache.set(url, result);
return result;
} catch (error) {
console.warn('[validateSignature] Failed to load thumbnail', error);
cache.set(url, null);
return null;
}
};
};

View File

@ -0,0 +1,60 @@
import { rgb } from 'pdf-lib';
type RgbTuple = [number, number, number];
const defaultLightPalette: Record<
'headerBackground' | 'accent' | 'textPrimary' | 'textMuted' | 'boxBackground' | 'boxBorder' | 'warning' | 'danger' | 'success' | 'neutral',
RgbTuple
> = {
headerBackground: [239, 246, 255],
accent: [59, 130, 246],
textPrimary: [30, 41, 59],
textMuted: [100, 116, 139],
boxBackground: [248, 250, 252],
boxBorder: [226, 232, 240],
warning: [234, 179, 8],
danger: [248, 113, 113],
success: [34, 197, 94],
neutral: [148, 163, 184],
};
const toRgb = ([r, g, b]: RgbTuple) => rgb(r / 255, g / 255, b / 255);
/**
* Utility function to get CSS variable values and convert them to pdf-lib RGB format.
* Falls back to sensible defaults when the CSS variable cannot be resolved.
*/
function getCssVariableAsRgb(variableName: string, fallback: RgbTuple) {
if (typeof window === 'undefined') {
return toRgb(fallback);
}
const value = getComputedStyle(document.documentElement).getPropertyValue(variableName).trim();
if (!value) {
console.warn(`CSS variable ${variableName} not found, using fallback`);
return toRgb(fallback);
}
const [r, g, b] = value.split(' ').map(Number);
if ([r, g, b].some((component) => Number.isNaN(component))) {
console.warn(`Invalid CSS variable format for ${variableName}: ${value}`);
return toRgb(fallback);
}
return rgb(r / 255, g / 255, b / 255);
}
export const colorPalette = {
headerBackground: getCssVariableAsRgb('--pdf-light-header-bg', defaultLightPalette.headerBackground),
accent: getCssVariableAsRgb('--pdf-light-accent', defaultLightPalette.accent),
textPrimary: getCssVariableAsRgb('--pdf-light-text-primary', defaultLightPalette.textPrimary),
textMuted: getCssVariableAsRgb('--pdf-light-text-muted', defaultLightPalette.textMuted),
boxBackground: getCssVariableAsRgb('--pdf-light-box-bg', defaultLightPalette.boxBackground),
boxBorder: getCssVariableAsRgb('--pdf-light-box-border', defaultLightPalette.boxBorder),
warning: getCssVariableAsRgb('--pdf-light-warning', defaultLightPalette.warning),
danger: getCssVariableAsRgb('--pdf-light-danger', defaultLightPalette.danger),
success: getCssVariableAsRgb('--pdf-light-success', defaultLightPalette.success),
neutral: getCssVariableAsRgb('--pdf-light-neutral', defaultLightPalette.neutral),
};

View File

@ -0,0 +1,51 @@
import { PDFFont } from 'pdf-lib';
export const wrapText = (text: string, font: PDFFont, fontSize: number, maxWidth: number): string[] => {
const lines: string[] = [];
const paragraphs = text.split(/\r?\n/);
paragraphs.forEach((paragraph) => {
const trimmed = paragraph.trim();
if (trimmed.length === 0) {
lines.push('');
return;
}
const words = trimmed.split(/\s+/);
let currentLine = '';
words.forEach((word) => {
const tentative = currentLine.length > 0 ? `${currentLine} ${word}` : word;
const width = font.widthOfTextAtSize(tentative, fontSize);
if (width <= maxWidth) {
currentLine = tentative;
} else {
if (currentLine.length > 0) {
lines.push(currentLine);
}
currentLine = word;
}
});
if (currentLine.length > 0) {
lines.push(currentLine);
}
});
return lines;
};
export const formatFileSize = (bytes?: number | null) => {
if (!bytes || bytes <= 0) return '--';
const units = ['B', 'KB', 'MB', 'GB'];
const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
const size = bytes / Math.pow(1024, exponent);
return `${size.toFixed(exponent === 0 ? 0 : 1)} ${units[exponent]}`;
};
export const formatDate = (value?: string | null) => {
if (!value) return '--';
const parsed = Date.parse(value);
if (!Number.isNaN(parsed)) {
return new Date(parsed).toLocaleString();
}
return value;
};

View File

@ -0,0 +1,37 @@
import type { TFunction } from 'i18next';
import { SignatureValidationReportEntry } from '../../../../types/validateSignature';
import { colorPalette } from './pdfPalette';
export const deriveEntryStatus = (
entry: Pick<SignatureValidationReportEntry, 'error' | 'signatures'>,
t: TFunction<'translation'>
) => {
if (entry.error) {
return {
text: t('validateSignature.status.invalid', 'Invalid'),
color: colorPalette.danger,
};
}
if (entry.signatures.length === 0) {
return {
text: t('validateSignature.noSignaturesShort', 'No signatures'),
color: colorPalette.neutral,
};
}
// File-level status is Valid only if every signature is cryptographically valid.
const allValid = entry.signatures.every((sig) => sig.valid);
if (allValid) {
return {
text: t('validateSignature.status.valid', 'Valid'),
color: colorPalette.success,
};
}
return {
text: t('validateSignature.status.invalid', 'Invalid'),
color: colorPalette.danger,
};
};

View File

@ -0,0 +1,98 @@
import { SignatureValidationReportEntry } from '../../../../types/validateSignature';
import { CSV_FILENAME, booleanToString, escapeCsvValue, keyUsagesToString } from './signatureUtils';
const buildCsvRows = (entries: SignatureValidationReportEntry[]): string[][] => {
const headers = [
'fileName',
'signatureIndex',
'valid',
'chainValid',
'trustValid',
'notExpired',
'revocationChecked',
'revocationStatus',
'signerName',
'signatureDate',
'reason',
'location',
'issuerDN',
'subjectDN',
'serialNumber',
'validFrom',
'validUntil',
'signatureAlgorithm',
'keySize',
'version',
'keyUsages',
'selfSigned',
'errorMessage'
];
const rows: string[][] = [headers];
entries.forEach((fileResult) => {
if (fileResult.signatures.length > 0) {
fileResult.signatures.forEach((signature, index) => {
rows.push([
fileResult.fileName,
String(index + 1),
booleanToString(signature.valid),
booleanToString(signature.chainValid),
booleanToString(signature.trustValid),
booleanToString(signature.notExpired),
booleanToString(signature.revocationChecked),
signature.revocationStatus || '',
signature.signerName || '',
signature.signatureDate || '',
signature.reason || '',
signature.location || '',
signature.issuerDN || '',
signature.subjectDN || '',
signature.serialNumber || '',
signature.validFrom || '',
signature.validUntil || '',
signature.signatureAlgorithm || '',
signature.keySize !== null && signature.keySize !== undefined ? String(signature.keySize) : '',
signature.version || '',
keyUsagesToString(signature.keyUsages),
booleanToString(signature.selfSigned),
signature.errorMessage || fileResult.error || ''
]);
});
} else {
rows.push([
fileResult.fileName,
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
fileResult.error || ''
]);
}
});
return rows;
};
export const createCsvFile = (entries: SignatureValidationReportEntry[]): File => {
const rows = buildCsvRows(entries);
const csv = rows.map((row) => row.map(escapeCsvValue).join(',')).join('\r\n');
return new File([csv], CSV_FILENAME, { type: 'text/csv;charset=utf-8;' });
};

View File

@ -0,0 +1,46 @@
import { SignatureValidationFileResult, SignatureValidationReportEntry } from '../../../../types/validateSignature';
import { FileContextSelectors } from '../../../../types/fileContext';
import type { FileId } from '../../../../types/file';
import type { TFunction } from 'i18next';
import { deriveEntryStatus } from './reportStatus';
interface BuildReportEntriesOptions {
results: SignatureValidationFileResult[];
selectors: FileContextSelectors;
generatedAt: number;
t?: TFunction<'translation'>;
}
export const buildReportEntries = ({
results,
selectors,
generatedAt,
t,
}: BuildReportEntriesOptions): SignatureValidationReportEntry[] => {
return results.map((entry) => {
const fileId = entry.fileId as FileId;
const stub = selectors.getStirlingFileStub(fileId);
const file = selectors.getFile(fileId);
let createdAtLabel: string | null = null;
const createdTimestamp = stub?.createdAt ?? null;
if (createdTimestamp) {
createdAtLabel = new Date(createdTimestamp).toLocaleString();
}
const fileSize = file?.size ?? stub?.size ?? entry.fileSize ?? null;
const lastModified = file?.lastModified ?? stub?.lastModified ?? entry.lastModified ?? null;
const statusMeta = t ? deriveEntryStatus(entry, t) : null;
return {
...entry,
thumbnailUrl: stub?.thumbnailUrl ?? null,
fileSize,
lastModified,
createdAtLabel,
summaryGeneratedAt: generatedAt,
statusText: statusMeta?.text ?? null,
};
});
};

View File

@ -0,0 +1,83 @@
import type { TFunction } from 'i18next';
import type { SignatureValidationSignature } from '../../../../types/validateSignature';
import { colorPalette } from './pdfPalette';
export type SignatureStatusKind = 'valid' | 'warning' | 'invalid' | 'neutral';
export interface SignatureStatus {
kind: SignatureStatusKind;
label: string;
details: string[];
}
export const computeSignatureStatus = (
signature: SignatureValidationSignature,
t: TFunction<'translation'>
): SignatureStatus => {
// Start with error
if (signature.errorMessage) {
return {
kind: 'invalid',
label: t('validateSignature.status.invalid', 'Invalid'),
details: [signature.errorMessage],
};
}
const issues: string[] = [];
const trustIssues: string[] = [];
if (!signature.valid) {
issues.push(t('validateSignature.issue.signatureInvalid', 'Signature cryptographic check failed'));
}
if (!signature.chainValid) {
trustIssues.push(t('validateSignature.issue.chainInvalid', 'Certificate chain invalid'));
}
if (!signature.trustValid) {
trustIssues.push(t('validateSignature.issue.trustInvalid', 'Certificate not trusted'));
}
if (!signature.notExpired) {
trustIssues.push(t('validateSignature.issue.certExpired', 'Certificate expired'));
}
// Use revocationStatus from backend; default to 'unknown' when absent
const revStatus = signature.revocationStatus ?? 'unknown';
if (revStatus === 'revoked') {
trustIssues.push(t('validateSignature.issue.certRevoked', 'Certificate revoked'));
} else if (revStatus === 'soft-fail') {
trustIssues.push(t('validateSignature.issue.certRevocationUnknown', 'Certificate revocation status unknown'));
}
// Aggregate all issues for details UI (ignore missing metadata fields; they are optional)
issues.push(...trustIssues);
// If cryptographic validation failed, mark as Invalid
if (!signature.valid) {
return {
kind: 'invalid',
label: t('validateSignature.status.invalid', 'Invalid'),
details: issues,
};
}
// Otherwise, mark as Valid regardless of optional field presence and trust warnings
return {
kind: 'valid',
label: t('validateSignature.status.valid', 'Valid'),
details: issues,
};
};
export const statusKindToPdfColor = (kind: SignatureStatusKind) => {
switch (kind) {
case 'valid':
return colorPalette.success;
case 'warning':
return colorPalette.warning;
case 'invalid':
return colorPalette.danger;
default:
return colorPalette.neutral;
}
};

View File

@ -0,0 +1,83 @@
import { SignatureValidationBackendResult, SignatureValidationSignature } from '../../../../types/validateSignature';
import type { StirlingFile } from '../../../../types/fileContext';
export const RESULT_JSON_FILENAME = 'signature-validation.json';
export const CSV_FILENAME = 'signature-validation.csv';
export const REPORT_PDF_FILENAME = 'signature-validation-report.pdf';
export const coerceString = (value: string | number | null | undefined): string => {
if (value === null || value === undefined) {
return '';
}
return String(value);
};
export const coerceNumber = (value: number | string | null | undefined): number | null => {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string') {
const parsed = parseInt(value, 10);
return Number.isNaN(parsed) ? null : parsed;
}
return null;
};
export const escapeCsvValue = (raw: string): string => {
let value = raw ?? '';
value = value.replace(/\r?\n|\r/g, ' ');
if (value.includes('"')) {
value = value.replace(/"/g, '""');
}
if (value.includes(',') || value.includes('"') || value.includes(';')) {
value = `"${value}"`;
}
return value;
};
export const booleanToString = (value: boolean | null | undefined): string => {
if (value === null || value === undefined) {
return '';
}
return value ? 'true' : 'false';
};
export const keyUsagesToString = (keyUsages: string[] | undefined): string => {
if (!keyUsages || keyUsages.length === 0) {
return '';
}
return keyUsages.join('; ');
};
export const normalizeBackendResult = (
item: SignatureValidationBackendResult,
stirlingFile: StirlingFile,
index: number
): SignatureValidationSignature => ({
id: `${stirlingFile.fileId}-${index}`,
valid: Boolean(item.valid),
chainValid: Boolean(item.chainValid),
trustValid: Boolean(item.trustValid),
notExpired: Boolean(item.notExpired),
revocationChecked:
item.revocationChecked === null || item.revocationChecked === undefined
? null
: Boolean(item.revocationChecked),
revocationStatus: item.revocationStatus ? coerceString(item.revocationStatus) : null,
validationTimeSource: item.validationTimeSource ? coerceString(item.validationTimeSource) : null,
signerName: coerceString(item.signerName),
signatureDate: coerceString(item.signatureDate),
reason: coerceString(item.reason),
location: coerceString(item.location),
issuerDN: coerceString(item.issuerDN),
subjectDN: coerceString(item.subjectDN),
serialNumber: coerceString(item.serialNumber),
validFrom: coerceString(item.validFrom),
validUntil: coerceString(item.validUntil),
signatureAlgorithm: coerceString(item.signatureAlgorithm),
keySize: coerceNumber(item.keySize),
version: coerceString(item.version),
keyUsages: Array.isArray(item.keyUsages) ? item.keyUsages.filter(Boolean).map(coerceString) : [],
selfSigned: Boolean(item.selfSigned),
errorMessage: item.errorMessage ? coerceString(item.errorMessage) : null,
});

View File

@ -261,6 +261,29 @@
--modal-nav-item-active-bg: rgba(10, 139, 255, 0.08);
--modal-content-bg: #ffffff;
--modal-header-border: rgba(0, 0, 0, 0.06);
/* PDF Report Colors (always light) */
--pdf-light-header-bg: 239 246 255;
--pdf-light-accent: 59 130 246;
--pdf-light-text-primary: 30 41 59;
--pdf-light-text-muted: 100 116 139;
--pdf-light-box-bg: 248 250 252;
--pdf-light-box-border: 226 232 240;
--pdf-light-warning: 234 179 8;
--pdf-light-danger: 248 113 113;
--pdf-light-success: 34 197 94;
--pdf-light-neutral: 148 163 184;
--pdf-light-status-valid-bg: 209 250 229;
--pdf-light-status-valid-text: 6 95 70;
--pdf-light-status-warning-bg: 254 243 199;
--pdf-light-status-warning-text: 146 64 14;
--pdf-light-status-invalid-bg: 254 226 226;
--pdf-light-status-invalid-text: 153 27 27;
--pdf-light-status-neutral-bg: 229 231 235;
--pdf-light-status-neutral-text: 55 65 81;
--pdf-light-report-container-bg: 249 250 251;
--pdf-light-simulated-page-bg: 255 255 255;
--pdf-light-simulated-page-text: 15 23 42;
}
[data-mantine-color-scheme="dark"] {

View File

@ -0,0 +1,163 @@
import { useEffect, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
import { createToolFlow } from '../components/tools/shared/createToolFlow';
import { useBaseTool } from '../hooks/tools/shared/useBaseTool';
import { BaseToolProps, ToolComponent } from '../types/tool';
import { useValidateSignatureParameters, defaultParameters } from '../hooks/tools/validateSignature/useValidateSignatureParameters';
import ValidateSignatureSettings from '../components/tools/validateSignature/ValidateSignatureSettings';
import ValidateSignatureResults from '../components/tools/validateSignature/ValidateSignatureResults';
import { useValidateSignatureOperation, ValidateSignatureOperationHook } from '../hooks/tools/validateSignature/useValidateSignatureOperation';
import ValidateSignatureReportView from '../components/tools/validateSignature/ValidateSignatureReportView';
import { useToolWorkflow } from '../contexts/ToolWorkflowContext';
import { useNavigationActions, useNavigationState } from '../contexts/NavigationContext';
import type { SignatureValidationReportData } from '../types/validateSignature';
const ValidateSignature = (props: BaseToolProps) => {
const { t } = useTranslation();
const { actions: navigationActions } = useNavigationActions();
const navigationState = useNavigationState();
const {
registerCustomWorkbenchView,
unregisterCustomWorkbenchView,
setCustomWorkbenchViewData,
clearCustomWorkbenchViewData,
} = useToolWorkflow();
const REPORT_VIEW_ID = 'validateSignatureReport';
const REPORT_WORKBENCH_ID = 'custom:validateSignatureReport' as const;
const reportIcon = useMemo(() => <PictureAsPdfIcon fontSize="small" />, []);
const base = useBaseTool(
'validateSignature',
useValidateSignatureParameters,
useValidateSignatureOperation,
props
);
const operation = base.operation as ValidateSignatureOperationHook;
const hasResults = operation.results.length > 0;
const showResultsStep = hasResults || base.operation.isLoading || !!base.operation.errorMessage;
useEffect(() => {
registerCustomWorkbenchView({
id: REPORT_VIEW_ID,
workbenchId: REPORT_WORKBENCH_ID,
label: t('validateSignature.report.shortTitle', 'Signature Report'),
icon: reportIcon,
component: ValidateSignatureReportView,
});
return () => {
clearCustomWorkbenchViewData(REPORT_VIEW_ID);
unregisterCustomWorkbenchView(REPORT_VIEW_ID);
};
}, [
clearCustomWorkbenchViewData,
registerCustomWorkbenchView,
reportIcon,
t,
unregisterCustomWorkbenchView,
]);
const reportData = useMemo<SignatureValidationReportData | null>(() => {
if (operation.results.length === 0) {
return null;
}
const generatedAt = operation.results[0].summaryGeneratedAt ?? Date.now();
return {
generatedAt,
entries: operation.results,
};
}, [operation.results]);
// Track last time we auto-navigated to the report so we don't override
// the user's manual tab change. Only navigate when a new report is generated.
const lastReportGeneratedAtRef = useRef<number | null>(null);
useEffect(() => {
if (reportData) {
setCustomWorkbenchViewData(REPORT_VIEW_ID, reportData);
const generatedAt = reportData.generatedAt ?? null;
const isNewReport = generatedAt && generatedAt !== lastReportGeneratedAtRef.current;
if (isNewReport) {
lastReportGeneratedAtRef.current = generatedAt;
if (navigationState.selectedTool === 'validateSignature' && navigationState.workbench !== REPORT_WORKBENCH_ID) {
navigationActions.setWorkbench(REPORT_WORKBENCH_ID);
}
}
} else {
clearCustomWorkbenchViewData(REPORT_VIEW_ID);
lastReportGeneratedAtRef.current = null;
}
}, [
clearCustomWorkbenchViewData,
navigationActions,
navigationState.selectedTool,
navigationState.workbench,
reportData,
setCustomWorkbenchViewData,
]);
return createToolFlow({
files: {
selectedFiles: base.selectedFiles,
isCollapsed: hasResults,
},
steps: [
{
title: t('validateSignature.settings.title', 'Validation Settings'),
isCollapsed: base.settingsCollapsed,
onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined,
content: (
<ValidateSignatureSettings
parameters={base.params.parameters}
onParameterChange={base.params.updateParameter}
disabled={base.operation.isLoading || base.endpointLoading}
/>
),
},
{
title: t('validateSignature.results', 'Validation Results'),
isVisible: showResultsStep,
isCollapsed: false,
content: (
<ValidateSignatureResults
operation={operation}
results={operation.results}
isLoading={base.operation.isLoading}
errorMessage={base.operation.errorMessage}
reportAvailable={Boolean(reportData)}
/>
),
},
],
executeButton: {
text: t('validateSignature.submit', 'Validate Signatures'),
loadingText: t('loading', 'Loading...'),
onClick: base.handleExecute,
disabled:
!base.params.validateParameters() ||
!base.hasFiles ||
base.operation.isLoading ||
!base.endpointEnabled,
isVisible: true,
},
review: {
isVisible: false,
operation: base.operation,
title: t('validateSignature.results', 'Validation Results'),
onUndo: base.handleUndo,
},
});
};
const ValidateSignatureTool = ValidateSignature as ToolComponent;
ValidateSignatureTool.tool = () => useValidateSignatureOperation;
ValidateSignatureTool.getDefaultParameters = () => ({ ...defaultParameters });
export default ValidateSignatureTool;

View File

@ -0,0 +1,77 @@
export interface SignatureValidationBackendResult {
valid: boolean;
chainValid: boolean;
trustValid: boolean;
chainValidationError?: string | null;
certPathLength?: number | null;
notExpired: boolean;
revocationChecked?: boolean | null;
revocationStatus?: string | null; // "not-checked" | "good" | "revoked" | "soft-fail" | "unknown"
validationTimeSource?: string | null; // "current" | "signing-time" | "timestamp"
signerName?: string | null;
signatureDate?: string | null;
reason?: string | null;
location?: string | null;
issuerDN?: string | null;
subjectDN?: string | null;
serialNumber?: string | null;
validFrom?: string | null;
validUntil?: string | null;
signatureAlgorithm?: string | null;
keySize?: number | string | null;
version?: string | number | null;
keyUsages?: string[] | null;
selfSigned?: boolean | null;
errorMessage?: string | null;
}
export interface SignatureValidationSignature {
id: string;
valid: boolean;
chainValid: boolean;
trustValid: boolean;
chainValidationError?: string | null;
certPathLength?: number | null;
notExpired: boolean;
revocationChecked?: boolean | null;
revocationStatus?: string | null; // "not-checked" | "good" | "revoked" | "soft-fail" | "unknown"
validationTimeSource?: string | null; // "current" | "signing-time" | "timestamp"
signerName: string;
signatureDate: string;
reason: string;
location: string;
issuerDN: string;
subjectDN: string;
serialNumber: string;
validFrom: string;
validUntil: string;
signatureAlgorithm: string;
keySize: number | null;
version: string;
keyUsages: string[];
selfSigned: boolean;
errorMessage: string | null;
}
export interface SignatureValidationFileResult {
fileId: string;
fileName: string;
signatures: SignatureValidationSignature[];
error?: string | null;
fileSize?: number | null;
lastModified?: number | null;
}
export interface SignatureValidationReportEntry extends SignatureValidationFileResult {
thumbnailUrl?: string | null;
fileSize?: number | null;
lastModified?: number | null;
createdAtLabel?: string | null;
summaryGeneratedAt?: number | null;
statusText?: string | null;
}
export interface SignatureValidationReportData {
generatedAt: number;
entries: SignatureValidationReportEntry[];
}

View File

@ -1,12 +1,21 @@
// Define workbench values once as source of truth
const WORKBENCH_TYPES = ['viewer', 'pageEditor', 'fileEditor'] as const;
export const BASE_WORKBENCH_TYPES = ['viewer', 'pageEditor', 'fileEditor'] as const;
// Workbench types - how the user interacts with content
export type WorkbenchType = typeof WORKBENCH_TYPES[number];
export type BaseWorkbenchType = typeof BASE_WORKBENCH_TYPES[number];
// Workbench types including custom views
export type WorkbenchType = BaseWorkbenchType | `custom:${string}`;
export const getDefaultWorkbench = (): WorkbenchType => 'fileEditor';
// Type guard using the same source of truth
export const isValidWorkbench = (value: string): value is WorkbenchType => {
return WORKBENCH_TYPES.includes(value as WorkbenchType);
if (BASE_WORKBENCH_TYPES.includes(value as BaseWorkbenchType)) {
return true;
}
return value.startsWith('custom:');
};
export const isBaseWorkbench = (value: WorkbenchType): value is BaseWorkbenchType => {
return BASE_WORKBENCH_TYPES.includes(value as BaseWorkbenchType);
};

1335
test_globalsign.pdf Normal file

File diff suppressed because it is too large Load Diff

BIN
test_irs_signed.pdf Normal file

Binary file not shown.