feat: add SDK support for PDF document signing and visual seal customization tools

This commit is contained in:
Claudecio Martins
2026-10-11 18:34:47 -03:00
parent d8d2e6ee46
commit b46d8b665f
12 changed files with 3497 additions and 121 deletions
@@ -56,7 +56,67 @@ public record CcspRequest(
String purpose,
@JsonProperty("challenge")
String challenge
String challenge,
@JsonProperty("pdf")
String pdf,
@JsonProperty("page")
Integer page,
@JsonProperty("page_choice")
String pageChoice,
@JsonProperty("position")
String position,
@JsonProperty("x")
Float x,
@JsonProperty("y")
Float y,
@JsonProperty("scale")
Double scale,
@JsonProperty("visible_seal")
Boolean visibleSeal,
@JsonProperty("seal_theme_id")
String sealThemeId,
@JsonProperty("seal_custom")
java.util.Map<String, Object> sealCustom,
@JsonProperty("reason")
String reason,
@JsonProperty("location")
String location,
@JsonProperty("cpf_display_mode")
String cpfDisplayMode,
@JsonProperty("include_date_time")
Boolean includeDateTime,
@JsonProperty("custom_extra_info")
String customExtraInfo,
@JsonProperty("xml")
String xml,
@JsonProperty("xml_base64")
String xmlBase64,
@JsonProperty("profile")
String profile,
@JsonProperty("target_element_tag")
String targetElementTag,
@JsonProperty("target_element_id")
String targetElementId
) implements Serializable {
public static final String PROTOCOL_VERSION = "1";
@@ -68,7 +128,9 @@ public record CcspRequest(
) {
this(version, requestId, applicationId, operation, algorithm, digest,
certificateHint, expiresAt, timestamp, nonce, signatureType,
null, null, null, null);
null, null, null, null,
null, null, null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null, null, null);
}
public boolean isVersionSupported() {
@@ -49,7 +49,16 @@ public record CcspResponse(
String documentName,
@JsonProperty("document_type")
String documentType
String documentType,
@JsonProperty("signed_pdf")
String signedPdf,
@JsonProperty("signed_xml")
String signedXml,
@JsonProperty("sha256")
String sha256
) implements Serializable {
public CcspResponse {
@@ -88,7 +97,58 @@ public record CcspResponse(
subjectName,
subjectCpfCnpj,
documentName,
documentType
documentType,
null,
null,
null
);
}
public static CcspResponse pdfSuccess(String requestId, String signedPdfBase64, String sha256,
String signatureBase64, String certBase64, List<String> chain,
String algorithm, String subjectName, String subjectCpfCnpj,
String documentName) {
return new CcspResponse(
CcspRequest.PROTOCOL_VERSION,
requestId,
STATUS_SIGNED,
signatureBase64,
certBase64,
chain,
algorithm,
null,
null,
subjectName,
subjectCpfCnpj,
documentName,
"PDF",
signedPdfBase64,
null,
sha256
);
}
public static CcspResponse xmlSuccess(String requestId, String signedXml, String sha256,
String signatureBase64, String certBase64, List<String> chain,
String algorithm, String subjectName, String subjectCpfCnpj,
String documentName) {
return new CcspResponse(
CcspRequest.PROTOCOL_VERSION,
requestId,
STATUS_SIGNED,
signatureBase64,
certBase64,
chain,
algorithm,
null,
null,
subjectName,
subjectCpfCnpj,
documentName,
"XML",
null,
signedXml,
sha256
);
}
@@ -108,7 +168,10 @@ public record CcspResponse(
subjectName,
subjectCpfCnpj,
null,
"AUTH_CHALLENGE"
"AUTH_CHALLENGE",
null,
null,
null
);
}
@@ -126,6 +189,9 @@ public record CcspResponse(
null,
null,
null,
null,
null,
null,
null
);
}
@@ -5,6 +5,7 @@ import br.com.cybercore.signer.core.crypto.SigningService;
import br.com.cybercore.signer.core.crypto.X509CertificateUtils;
import br.com.cybercore.signer.core.domain.ManagedCertificate;
import br.com.cybercore.signer.core.domain.SigningContext;
import br.com.cybercore.signer.core.domain.SigningKeyMaterial;
import br.com.cybercore.signer.core.domain.SigningResult;
import java.security.MessageDigest;
import br.com.cybercore.signer.core.provider.CertificateManager;
@@ -22,6 +23,20 @@ import com.sun.net.httpserver.HttpServer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import br.com.cybercore.signer.core.pdf.PdfSealPageChoice;
import br.com.cybercore.signer.core.pdf.PdfSealPosition;
import br.com.cybercore.signer.core.pdf.PdfSignatureOptions;
import br.com.cybercore.signer.core.pdf.PdfSigningService;
import br.com.cybercore.signer.core.pdf.seal.CpfDisplayMode;
import br.com.cybercore.signer.core.pdf.seal.PdfSealTheme;
import br.com.cybercore.signer.core.pdf.seal.PdfSealThemeRegistry;
import br.com.cybercore.signer.core.xml.XmlSignatureOptions;
import br.com.cybercore.signer.core.xml.XmlSignatureProfile;
import br.com.cybercore.signer.core.xml.XmlSigningService;
import java.util.Arrays;
import java.util.UUID;
import java.time.format.DateTimeFormatter;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
@@ -56,6 +71,8 @@ public class CcspServer {
private final int port;
private final CertificateManager certificateManager;
private final SigningService signingService;
private final PdfSigningService pdfSigningService;
private final XmlSigningService xmlSigningService;
private final ApplicationRegistry applicationRegistry;
private final NonceManager nonceManager;
private final OriginValidator originValidator;
@@ -75,6 +92,8 @@ public class CcspServer {
this.port = port;
this.certificateManager = Objects.requireNonNull(certificateManager, "certificateManager cannot be null");
this.signingService = Objects.requireNonNull(signingService, "signingService cannot be null");
this.pdfSigningService = new PdfSigningService(this.certificateManager);
this.xmlSigningService = new XmlSigningService(this.certificateManager);
this.applicationRegistry = applicationRegistry != null ? applicationRegistry : ApplicationRegistry.getInstance();
this.promptListener = promptListener;
this.nonceManager = new NonceManager();
@@ -114,6 +133,9 @@ public class CcspServer {
httpServer.createContext("/ccsp/v1/sign", new SignHandler());
httpServer.createContext("/ccsp/v1/authenticate", new SignHandler());
httpServer.createContext("/ccsp/v1/authorize", new AuthorizeHandler());
httpServer.createContext("/ccsp/v1/sign-pdf", new SignPdfHandler());
httpServer.createContext("/ccsp/v1/sign-xml", new SignXmlHandler());
httpServer.createContext("/ccsp/v1/seals", new SealsHandler());
httpServer.start();
running = true;
@@ -458,6 +480,480 @@ public class CcspServer {
}
}
private class SignPdfHandler implements HttpHandler {
@Override
public void handle(HttpExchange exchange) throws IOException {
if (handleCors(exchange)) return;
if (!"POST".equalsIgnoreCase(exchange.getRequestMethod())) {
sendErrorResponse(exchange, 405, "METHOD_NOT_ALLOWED", "Apenas POST é suportado.");
return;
}
String origin = getOrigin(exchange);
CcspRequest request;
try (InputStream is = exchange.getRequestBody()) {
request = objectMapper.readValue(is, CcspRequest.class);
} catch (Exception e) {
sendCcspError(exchange, 400, null, CcspErrorCode.SIGNATURE_FAILED, "Payload de requisição inválido.");
return;
}
if (!request.isVersionSupported()) {
sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.COMMUNICATION_ERROR,
"Versão do protocolo não suportada: " + request.version());
return;
}
if (request.isExpired()) {
sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.REQUEST_EXPIRED,
"A requisição de assinatura expirou.");
return;
}
if (request.nonce() != null && !request.nonce().isBlank()) {
boolean accepted = nonceManager.registerNonce(request.nonce());
if (!accepted) {
sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.REPLAY_DETECTED,
"Requisição duplicada detectada (replay prevenido).");
return;
}
}
String appId = request.applicationId();
if (appId == null || appId.isBlank()) {
sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.APPLICATION_NOT_AUTHORIZED,
"application_id é obrigatório.");
return;
}
if (!applicationRegistry.isAuthorized(appId, origin)) {
boolean approved = promptApplicationAuthorization(origin, "Aplicação Web", appId);
if (!approved) {
sendCcspError(exchange, 403, request.requestId(), CcspErrorCode.APPLICATION_NOT_AUTHORIZED,
"Aplicação não autorizada pelo usuário.");
return;
}
}
if (request.pdf() == null || request.pdf().isBlank()) {
sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.SIGNATURE_FAILED,
"O campo 'pdf' contendo o documento em Base64 é obrigatório.");
return;
}
byte[] pdfBytes;
try {
pdfBytes = Base64.getDecoder().decode(request.pdf());
} catch (Exception e) {
sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.SIGNATURE_FAILED,
"O campo 'pdf' não contém dados Base64 válidos.");
return;
}
Optional<ManagedCertificate> certOpt = certificateManager.findByHint(request.certificateHint());
if (certOpt.isEmpty()) {
sendCcspError(exchange, 404, request.requestId(), CcspErrorCode.CERTIFICATE_NOT_FOUND,
"Nenhum certificado correspondente encontrado.");
return;
}
ManagedCertificate cert = certOpt.get();
if (promptListener != null) {
try {
CompletableFuture<Boolean> approvalFuture = promptListener.requestSignatureConfirmation(request, cert);
Boolean confirmed = approvalFuture.get(60, TimeUnit.SECONDS);
if (!Boolean.TRUE.equals(confirmed)) {
sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.USER_CANCELLED,
"Operação de assinatura cancelada pelo usuário.");
return;
}
} catch (Exception e) {
logger.warn("Falha ou timeout na confirmação visual de assinatura: {}", e.getMessage());
sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.USER_CANCELLED,
"Confirmação da assinatura expirou ou foi cancelada.");
return;
}
}
PdfSealTheme sealTheme;
if (request.sealCustom() != null && !request.sealCustom().isEmpty()) {
sealTheme = parseCustomSealTheme(request.sealCustom());
} else if (request.sealThemeId() != null && !request.sealThemeId().isBlank()) {
sealTheme = PdfSealThemeRegistry.findById(request.sealThemeId())
.orElse(PdfSealThemeRegistry.getDefaultTheme());
} else {
sealTheme = PdfSealThemeRegistry.getDefaultTheme();
}
PdfSealPosition position = PdfSealPosition.BOTTOM_RIGHT;
if (request.x() != null && request.y() != null) {
position = PdfSealPosition.CUSTOM;
} else if (request.position() != null) {
try {
position = PdfSealPosition.valueOf(request.position().toUpperCase());
} catch (Exception ignored) {}
}
PdfSealPageChoice pageChoice = PdfSealPageChoice.LAST_PAGE;
if (request.pageChoice() != null) {
try {
pageChoice = PdfSealPageChoice.valueOf(request.pageChoice().toUpperCase());
} catch (Exception ignored) {}
}
CpfDisplayMode cpfMode = sealTheme.defaultCpfMode();
if (request.cpfDisplayMode() != null) {
try {
cpfMode = CpfDisplayMode.valueOf(request.cpfDisplayMode().toUpperCase());
} catch (Exception ignored) {}
}
boolean includeDateTime = request.includeDateTime() != null
? request.includeDateTime()
: sealTheme.defaultIncludeDateTime();
boolean visibleSeal = request.visibleSeal() == null || Boolean.TRUE.equals(request.visibleSeal());
double customScale = request.scale() != null && request.scale() > 0 ? request.scale() : 1.0;
PdfSignatureOptions options = new PdfSignatureOptions(
cert.id(),
null,
visibleSeal,
sealTheme,
position,
pageChoice,
request.reason() != null ? request.reason() : "Assinatura Digital de Documento",
request.location() != null ? request.location() : "Brasil",
request.x(),
request.y(),
request.page(),
cpfMode,
includeDateTime,
request.customExtraInfo(),
customScale
);
signLock.lock();
try {
byte[] signedPdfBytes = pdfSigningService.signPdf(pdfBytes, options);
String signedPdfBase64 = Base64.getEncoder().encodeToString(signedPdfBytes);
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] shaBytes = md.digest(signedPdfBytes);
String shaHex = bytesToHex(shaBytes);
Optional<SigningKeyMaterial> keyMat = certificateManager.getKeyMaterial(cert.id(), null);
String certBase64 = null;
List<String> chainBase64 = Collections.emptyList();
if (keyMat.isPresent()) {
try {
certBase64 = Base64.getEncoder().encodeToString(keyMat.get().signingCertificate().getEncoded());
chainBase64 = Arrays.stream(keyMat.get().certificateChain())
.map(c -> {
try {
return Base64.getEncoder().encodeToString(c.getEncoded());
} catch (Exception e) {
return "";
}
})
.filter(s -> !s.isEmpty())
.toList();
} catch (Exception ignored) {}
}
String subjectName = X509CertificateUtils.extractHolderName(cert.commonName());
String cpfCnpj = cert.cpf() != null ? cert.cpf() : cert.cnpj();
CcspResponse response = CcspResponse.pdfSuccess(
request.requestId(),
signedPdfBase64,
shaHex,
null,
certBase64,
chainBase64,
"SHA256withRSA",
subjectName,
cpfCnpj,
request.documentName() != null ? request.documentName() : "documento.pdf"
);
sendJsonResponse(exchange, 200, response);
} catch (CertificateProviderException cpe) {
sendCcspError(exchange, 400, request.requestId(), cpe.getErrorCode(), cpe.getMessage());
} catch (Exception e) {
logger.error("Falha ao assinar PDF: {}", e.getMessage(), e);
sendCcspError(exchange, 500, request.requestId(), CcspErrorCode.SIGNATURE_FAILED,
"Erro ao assinar PDF: " + e.getMessage());
} finally {
signLock.unlock();
}
}
}
private class SignXmlHandler implements HttpHandler {
@Override
public void handle(HttpExchange exchange) throws IOException {
if (handleCors(exchange)) return;
if (!"POST".equalsIgnoreCase(exchange.getRequestMethod())) {
sendErrorResponse(exchange, 405, "METHOD_NOT_ALLOWED", "Apenas POST é suportado.");
return;
}
String origin = getOrigin(exchange);
CcspRequest request;
try (InputStream is = exchange.getRequestBody()) {
request = objectMapper.readValue(is, CcspRequest.class);
} catch (Exception e) {
sendCcspError(exchange, 400, null, CcspErrorCode.SIGNATURE_FAILED, "Payload de requisição inválido.");
return;
}
if (!request.isVersionSupported()) {
sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.COMMUNICATION_ERROR,
"Versão do protocolo não suportada: " + request.version());
return;
}
if (request.isExpired()) {
sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.REQUEST_EXPIRED,
"A requisição de assinatura expirou.");
return;
}
if (request.nonce() != null && !request.nonce().isBlank()) {
boolean accepted = nonceManager.registerNonce(request.nonce());
if (!accepted) {
sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.REPLAY_DETECTED,
"Requisição duplicada detectada (replay prevenido).");
return;
}
}
String appId = request.applicationId();
if (appId == null || appId.isBlank()) {
sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.APPLICATION_NOT_AUTHORIZED,
"application_id é obrigatório.");
return;
}
if (!applicationRegistry.isAuthorized(appId, origin)) {
boolean approved = promptApplicationAuthorization(origin, "Aplicação Web", appId);
if (!approved) {
sendCcspError(exchange, 403, request.requestId(), CcspErrorCode.APPLICATION_NOT_AUTHORIZED,
"Aplicação não autorizada pelo usuário.");
return;
}
}
byte[] xmlBytes;
if (request.xmlBase64() != null && !request.xmlBase64().isBlank()) {
try {
xmlBytes = Base64.getDecoder().decode(request.xmlBase64());
} catch (Exception e) {
sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.SIGNATURE_FAILED,
"O campo 'xml_base64' não contém dados válidos.");
return;
}
} else if (request.xml() != null && !request.xml().isBlank()) {
xmlBytes = request.xml().getBytes(StandardCharsets.UTF_8);
} else {
sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.SIGNATURE_FAILED,
"É obrigatório fornecer o XML em texto ('xml') ou Base64 ('xml_base64').");
return;
}
Optional<ManagedCertificate> certOpt = certificateManager.findByHint(request.certificateHint());
if (certOpt.isEmpty()) {
sendCcspError(exchange, 404, request.requestId(), CcspErrorCode.CERTIFICATE_NOT_FOUND,
"Nenhum certificado correspondente encontrado.");
return;
}
ManagedCertificate cert = certOpt.get();
if (promptListener != null) {
try {
CompletableFuture<Boolean> approvalFuture = promptListener.requestSignatureConfirmation(request, cert);
Boolean confirmed = approvalFuture.get(60, TimeUnit.SECONDS);
if (!Boolean.TRUE.equals(confirmed)) {
sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.USER_CANCELLED,
"Operação de assinatura cancelada pelo usuário.");
return;
}
} catch (Exception e) {
logger.warn("Falha ou timeout na confirmação visual de assinatura XML: {}", e.getMessage());
sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.USER_CANCELLED,
"Confirmação da assinatura expirou ou foi cancelada.");
return;
}
}
XmlSignatureProfile profile = XmlSignatureProfile.XADES_BES;
if (request.profile() != null) {
try {
profile = XmlSignatureProfile.valueOf(request.profile().toUpperCase());
} catch (Exception ignored) {}
}
String algo = request.algorithm() != null && !request.algorithm().isBlank()
? request.algorithm()
: XmlSignatureOptions.DEFAULT_ALGORITHM;
XmlSignatureOptions options = new XmlSignatureOptions(
cert.id(),
null,
request.targetElementTag(),
request.targetElementId(),
algo,
profile
);
signLock.lock();
try {
byte[] signedXmlBytes = xmlSigningService.signXml(xmlBytes, options);
String signedXmlStr = new String(signedXmlBytes, StandardCharsets.UTF_8);
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] shaBytes = md.digest(signedXmlBytes);
String shaHex = bytesToHex(shaBytes);
Optional<SigningKeyMaterial> keyMat = certificateManager.getKeyMaterial(cert.id(), null);
String certBase64 = null;
List<String> chainBase64 = Collections.emptyList();
if (keyMat.isPresent()) {
try {
certBase64 = Base64.getEncoder().encodeToString(keyMat.get().signingCertificate().getEncoded());
chainBase64 = Arrays.stream(keyMat.get().certificateChain())
.map(c -> {
try {
return Base64.getEncoder().encodeToString(c.getEncoded());
} catch (Exception e) {
return "";
}
})
.filter(s -> !s.isEmpty())
.toList();
} catch (Exception ignored) {}
}
String subjectName = X509CertificateUtils.extractHolderName(cert.commonName());
String cpfCnpj = cert.cpf() != null ? cert.cpf() : cert.cnpj();
CcspResponse response = CcspResponse.xmlSuccess(
request.requestId(),
signedXmlStr,
shaHex,
null,
certBase64,
chainBase64,
algo,
subjectName,
cpfCnpj,
request.documentName() != null ? request.documentName() : "documento.xml"
);
sendJsonResponse(exchange, 200, response);
} catch (CertificateProviderException cpe) {
sendCcspError(exchange, 400, request.requestId(), cpe.getErrorCode(), cpe.getMessage());
} catch (Exception e) {
logger.error("Falha ao assinar XML: {}", e.getMessage(), e);
sendCcspError(exchange, 500, request.requestId(), CcspErrorCode.SIGNATURE_FAILED,
"Erro ao assinar XML: " + e.getMessage());
} finally {
signLock.unlock();
}
}
}
private class SealsHandler implements HttpHandler {
@Override
public void handle(HttpExchange exchange) throws IOException {
if (handleCors(exchange)) return;
if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) {
sendErrorResponse(exchange, 405, "METHOD_NOT_ALLOWED", "Apenas GET é suportado.");
return;
}
List<Map<String, Object>> sealList = PdfSealThemeRegistry.listThemes().stream().map(t -> {
Map<String, Object> dto = new HashMap<>();
dto.put("id", t.id());
dto.put("name", t.name());
dto.put("description", t.description());
dto.put("width", t.width());
dto.put("height", t.height());
dto.put("primary_color", t.primaryColor());
dto.put("secondary_color", t.secondaryColor());
dto.put("background_color", t.backgroundColor());
dto.put("border_color", t.borderColor());
dto.put("text_color", t.textColor());
dto.put("icon_style", t.iconStyle());
dto.put("is_custom", t.isCustom());
dto.put("default_cpf_mode", t.defaultCpfMode() != null ? t.defaultCpfMode().name() : "FULL");
dto.put("default_include_date_time", t.defaultIncludeDateTime());
return dto;
}).toList();
Map<String, Object> resp = new HashMap<>();
resp.put("version", CcspRequest.PROTOCOL_VERSION);
resp.put("status", "success");
resp.put("default_seal_id", PdfSealThemeRegistry.getDefaultTheme().id());
resp.put("seals", sealList);
sendJsonResponse(exchange, 200, resp);
}
}
private PdfSealTheme parseCustomSealTheme(Map<String, Object> map) {
if (map == null || map.isEmpty()) {
return PdfSealThemeRegistry.getDefaultTheme();
}
String id = "custom-" + UUID.randomUUID().toString().substring(0, 8);
String name = (String) map.getOrDefault("name", "Selo Personalizado");
String description = (String) map.getOrDefault("description", "Selo customizado via aplicação web");
int width = map.containsKey("width") ? ((Number) map.get("width")).intValue() : 320;
int height = map.containsKey("height") ? ((Number) map.get("height")).intValue() : 82;
String primaryColor = (String) map.getOrDefault("primaryColor", "#0d47a1");
String secondaryColor = (String) map.getOrDefault("secondaryColor", "#0284c7");
String backgroundColor = (String) map.getOrDefault("backgroundColor", "#ffffff");
String borderColor = (String) map.getOrDefault("borderColor", "#cbd5e1");
String textColor = (String) map.getOrDefault("textColor", "#0f172a");
String customHeader = (String) map.get("customHeader");
String customFooter = (String) map.get("customFooter");
String iconStyle = (String) map.getOrDefault("iconStyle", "shield");
String customLogoBase64 = (String) map.get("customLogoBase64");
String customFieldLabel = (String) map.get("customFieldLabel");
String customFieldValue = (String) map.get("customFieldValue");
CpfDisplayMode cpfMode = CpfDisplayMode.FULL;
if (map.containsKey("defaultCpfMode") && map.get("defaultCpfMode") != null) {
try {
cpfMode = CpfDisplayMode.valueOf(map.get("defaultCpfMode").toString().toUpperCase());
} catch (Exception ignored) {}
}
boolean includeDateTime = !map.containsKey("defaultIncludeDateTime") || Boolean.TRUE.equals(map.get("defaultIncludeDateTime"));
return new PdfSealTheme(
id, name, description, width, height,
primaryColor, secondaryColor, backgroundColor, borderColor, textColor,
null, customHeader, customFooter, iconStyle,
cpfMode, includeDateTime, true, customLogoBase64,
customFieldLabel, customFieldValue
);
}
private static String bytesToHex(byte[] bytes) {
if (bytes == null) return "";
StringBuilder sb = new StringBuilder(bytes.length * 2);
for (byte b : bytes) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}
// =========================================================================
// Utilitários de Segurança e HTTP
// =========================================================================
@@ -11,22 +11,43 @@ import br.com.cybercore.signer.core.provider.CertificateProvider;
import br.com.cybercore.signer.core.provider.ProviderType;
import br.com.cybercore.signer.core.security.ApplicationRegistry;
import br.com.cybercore.signer.core.security.AuthorizedApplication;
import br.com.cybercore.signer.core.domain.SigningKeyMaterial;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.cert.X509v3CertificateBuilder;
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
import org.bouncycastle.operator.ContentSigner;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.ByteArrayOutputStream;
import java.math.BigInteger;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.MessageDigest;
import java.security.cert.X509Certificate;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Base64;
import java.util.Date;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
@@ -49,6 +70,28 @@ class CcspServerTest {
SigningService signingService = mock(SigningService.class);
ApplicationRegistry appRegistry = new ApplicationRegistry(tempDir.resolve("apps.json"));
// Gera par de chaves e certificado real em memória para suporte a PDF e XML
KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");
kpg.initialize(2048);
KeyPair kp = kpg.generateKeyPair();
long now = System.currentTimeMillis();
Date notBefore = new Date(now - 100000);
Date notAfter = new Date(now + 365L * 24 * 3600 * 1000);
X500Name subject = new X500Name("CN=Teste ICP-Brasil:12345678900, O=CyberCore Testes, C=BR");
X509v3CertificateBuilder certBuilder = new JcaX509v3CertificateBuilder(
subject,
BigInteger.valueOf(System.currentTimeMillis()),
notBefore,
notAfter,
subject,
kp.getPublic()
);
ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA").build(kp.getPrivate());
X509Certificate cert = new JcaX509CertificateConverter().getCertificate(certBuilder.build(signer));
// Registra uma aplicação de teste
appRegistry.register(new AuthorizedApplication(
"app-test",
@@ -64,8 +107,8 @@ class CcspServerTest {
"AliasTeste",
CertificateType.A1,
CertificateStatus.AVAILABLE,
"CN=Teste ICP-Brasil, C=BR",
"CN=AC Raiz, C=BR",
"CN=Teste ICP-Brasil:12345678900, O=CyberCore Testes, C=BR",
"CN=Teste ICP-Brasil:12345678900, O=CyberCore Testes, C=BR",
"12345678",
"a1b2c3",
"d4e5f6",
@@ -89,6 +132,9 @@ class CcspServerTest {
@Override public SigningResult sign(SigningContext context) {
return new SigningResult(context.requestId(), new byte[]{1, 2, 3}, "mock-cert", List.of("mock-cert"), "SHA256withRSA", Instant.now());
}
@Override public Optional<SigningKeyMaterial> getKeyMaterial(String certificateId, char[] pinOrPassword) {
return Optional.of(new SigningKeyMaterial(kp.getPrivate(), new X509Certificate[]{cert}, null));
}
};
certManager.registerProvider(dummyProvider);
@@ -149,7 +195,7 @@ class CcspServerTest {
HttpResponse<String> resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
assertThat(resp.statusCode()).isEqualTo(200);
assertThat(resp.body()).contains("\"status\":\"success\"");
assertThat(resp.body()).contains("CN=Teste ICP-Brasil, C=BR");
assertThat(resp.body()).contains("Teste ICP-Brasil");
}
@Test
@@ -320,4 +366,196 @@ class CcspServerTest {
assertThat(response.body()).contains("\"document_name\":\"proposta_comercial.pdf\"");
assertThat(response.body()).contains("\"document_type\":\"PDF\"");
}
private byte[] createSamplePdf() throws Exception {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (PDDocument doc = new PDDocument()) {
PDPage page = new PDPage();
doc.addPage(page);
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
cs.beginText();
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA_BOLD), 12);
cs.newLineAtOffset(50, 700);
cs.showText("PDF de Teste CCSP - CyberCore Signer");
cs.endText();
}
doc.save(baos);
}
return baos.toByteArray();
}
@Test
@DisplayName("Endpoint /seals deve listar temas de selos visuais disponíveis")
void testGetSealsThemes() throws Exception {
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/ccsp/v1/seals?application_id=app-test"))
.header("Origin", "https://meusistema.com.br")
.GET()
.build();
HttpResponse<String> resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
assertThat(resp.statusCode()).isEqualTo(200);
assertThat(resp.body()).contains("\"status\":\"success\"");
assertThat(resp.body()).contains("\"seals\":");
assertThat(resp.body()).contains("icp-brasil");
assertThat(resp.body()).contains("gov-br");
}
@Test
@DisplayName("Endpoint /sign-pdf deve assinar documento PDF em Base64 e retornar signed_pdf válido")
void testSignPdfSuccess() throws Exception {
byte[] pdfBytes = createSamplePdf();
String pdfBase64 = Base64.getEncoder().encodeToString(pdfBytes);
String jsonPayload = """
{
"version": "1",
"request_id": "%s",
"application_id": "app-test",
"operation": "sign-pdf",
"certificate_hint": "cert-1",
"pdf": "%s",
"page": 1,
"x": 60.0,
"y": 60.0,
"scale": 1.0,
"seal_theme_id": "icp-brasil",
"reason": "Aprovado e assinado via CCSP",
"location": "Curitiba, PR",
"nonce": "%s"
}
""".formatted(UUID.randomUUID(), pdfBase64, UUID.randomUUID());
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/ccsp/v1/sign-pdf"))
.header("Content-Type", "application/json")
.header("Origin", "https://meusistema.com.br")
.POST(HttpRequest.BodyPublishers.ofString(jsonPayload))
.build();
HttpResponse<String> resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
assertThat(resp.statusCode()).isEqualTo(200);
assertThat(resp.body()).contains("\"status\":\"signed\"");
assertThat(resp.body()).contains("\"signed_pdf\":");
assertThat(resp.body()).contains("\"sha256\":");
// Verifica que o PDF assinado retornado pode ser carregado pelo PDFBox
int signedPdfIdx = resp.body().indexOf("\"signed_pdf\":\"") + 14;
int endIdx = resp.body().indexOf("\"", signedPdfIdx);
String signedPdfBase64 = resp.body().substring(signedPdfIdx, endIdx);
byte[] signedBytes = Base64.getDecoder().decode(signedPdfBase64);
try (PDDocument signedDoc = Loader.loadPDF(signedBytes)) {
assertThat(signedDoc.getNumberOfPages()).isEqualTo(1);
assertThat(signedDoc.getSignatureDictionaries()).isNotEmpty();
}
}
@Test
@DisplayName("Endpoint /sign-pdf com seal_custom inline deve assinar com sucesso")
void testSignPdfCustomSealSuccess() throws Exception {
byte[] pdfBytes = createSamplePdf();
String pdfBase64 = Base64.getEncoder().encodeToString(pdfBytes);
String jsonPayload = """
{
"version": "1",
"request_id": "%s",
"application_id": "app-test",
"operation": "sign-pdf",
"certificate_hint": "cert-1",
"pdf": "%s",
"page": 1,
"x": 40.0,
"y": 40.0,
"scale": 0.9,
"seal_custom": {
"name": "Custom Seal Test",
"title": "SELADO DIGITALMENTE",
"primary_color": "#2c3e50",
"accent_color": "#18bc9c",
"show_timestamp": true,
"show_document_hash": true
},
"nonce": "%s"
}
""".formatted(UUID.randomUUID(), pdfBase64, UUID.randomUUID());
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/ccsp/v1/sign-pdf"))
.header("Content-Type", "application/json")
.header("Origin", "https://meusistema.com.br")
.POST(HttpRequest.BodyPublishers.ofString(jsonPayload))
.build();
HttpResponse<String> resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
assertThat(resp.statusCode()).isEqualTo(200);
assertThat(resp.body()).contains("\"status\":\"signed\"");
assertThat(resp.body()).contains("\"signed_pdf\":");
}
@Test
@DisplayName("Endpoint /sign-xml deve assinar XML plano com perfil XMLDSig")
void testSignXmlSuccess() throws Exception {
String xmlContent = "<documento id=\"doc-123\"><dados>Contrato de Teste</dados></documento>";
String jsonPayload = """
{
"version": "1",
"request_id": "%s",
"application_id": "app-test",
"operation": "sign-xml",
"certificate_hint": "cert-1",
"xml": "%s",
"profile": "XMLDSIG",
"nonce": "%s"
}
""".formatted(UUID.randomUUID(), xmlContent.replace("\"", "\\\""), UUID.randomUUID());
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/ccsp/v1/sign-xml"))
.header("Content-Type", "application/json")
.header("Origin", "https://meusistema.com.br")
.POST(HttpRequest.BodyPublishers.ofString(jsonPayload))
.build();
HttpResponse<String> resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
assertThat(resp.statusCode()).isEqualTo(200);
assertThat(resp.body()).contains("\"status\":\"signed\"");
assertThat(resp.body()).contains("\"signed_xml\":");
assertThat(resp.body()).contains("SignatureValue");
}
@Test
@DisplayName("Endpoint /sign-xml deve assinar XML em Base64 com perfil XAdES-BES")
void testSignXmlBase64XadesBes() throws Exception {
String xmlContent = "<pedido id=\"ped-999\"><valor>150.00</valor></pedido>";
String xmlBase64 = Base64.getEncoder().encodeToString(xmlContent.getBytes(StandardCharsets.UTF_8));
String jsonPayload = """
{
"version": "1",
"request_id": "%s",
"application_id": "app-test",
"operation": "sign-xml",
"certificate_hint": "cert-1",
"xml_base64": "%s",
"profile": "XADES_BES",
"nonce": "%s"
}
""".formatted(UUID.randomUUID(), xmlBase64, UUID.randomUUID());
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/ccsp/v1/sign-xml"))
.header("Content-Type", "application/json")
.header("Origin", "https://meusistema.com.br")
.POST(HttpRequest.BodyPublishers.ofString(jsonPayload))
.build();
HttpResponse<String> resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
assertThat(resp.statusCode()).isEqualTo(200);
assertThat(resp.body()).contains("\"status\":\"signed\"");
assertThat(resp.body()).contains("\"signed_xml\":");
assertThat(resp.body()).contains("QualifyingProperties");
}
}