4 Commits
14 changed files with 1153 additions and 226 deletions
+81 -19
View File
@@ -1,16 +1,27 @@
# Assinaturas XML (XMLDSig e ICP-Brasil)
# Assinaturas XML no Padrão XAdES e ICP-Brasil
Este documento descreve a especificação e o funcionamento da assinatura digital de arquivos XML no padrão **W3C XMLDSig (XML Digital Signatures)** e **ICP-Brasil** no CyberCore Signer.
Este documento descreve a especificação, conformidade e o funcionamento da assinatura digital de arquivos XML no padrão avançado **XAdES (XML Advanced Electronic Signatures - ETSI TS 101 903 e ICP-Brasil DOC-ICP-15.03)** e **W3C XMLDSig** no CyberCore Signer.
---
## 1. Visão Geral
O CyberCore Signer fornece suporte nativo a assinaturas digitais XML utilizando a engine padronizada e segura da plataforma Java (`javax.xml.crypto.dsig`).
O CyberCore Signer fornece suporte nativo a assinaturas digitais XML utilizando a engine padronizada e segura da plataforma Java (`javax.xml.crypto.dsig`), garantindo conformidade com o padrão **XAdES-BES** adotado pelo ITI (Instituto Nacional de Tecnologia da Informação) na infraestrutura ICP-Brasil (perfil AD-RB).
### 1.1 Casos de Uso Suportados
### 1.1 Perfis Suportados
- **Documento Inteiro Envelopado (`URI=""`):** Contratos, despachos, declarações e relatórios em formato XML.
* **`XAdES-BES` (Padrão Recomendado / ICP-Brasil):**
- Conforme especificação técnica **ETSI TS 101 903** e norma **DOC-ICP-15.03**.
- Encapsula o elemento `<ds:Object>` contendo `<xades:QualifyingProperties>` e `<xades:SignedProperties>`.
- Inclui propriedade qualificada de data/hora da assinatura (`<xades:SigningTime>`) em formato UTC ISO-8601.
- Inclui vínculo criptográfico irrefutável do certificado do signatário (`<xades:SigningCertificate>`) contendo o digest SHA-256 do certificado DER e referências do emissor/serial (`<xades:IssuerSerial>`).
- Protege as propriedades qualificadas através de uma `<ds:Reference>` em `<ds:SignedInfo>` com `Type="http://uri.etsi.org/01903#SignedProperties"`.
* **`XMLDSig` Clássico (W3C Enveloped):**
- Mantido para compatibilidade com layouts estritos de notas fiscais eletrônicas legadas (ex: NF-e, CT-e, MDF-e SEFAZ).
### 1.2 Casos de Uso Suportados
- **Documento Inteiro Envelopado (`URI=""`):** Contratos, despachos, declarações, laudos e relatórios em XML.
- **Assinatura de Elementos Específicos por ID:**
- **NF-e (Nota Fiscal Eletrônica):** Assinatura da tag `<infNFe Id="NFe...">`
- **CT-e (Conhecimento de Transporte Eletrônico):** Assinatura da tag `<infCte Id="CTe...">`
@@ -19,19 +30,69 @@ O CyberCore Signer fornece suporte nativo a assinaturas digitais XML utilizando
---
## 2. Padrões Criptográficos
## 2. Estrutura Criptográfica do XAdES-BES
- **Canonicalização (C14N):** `http://www.w3.org/TR/2001/REC-xml-c14n-20010315` (C14N inclusiva sem comentários).
- **Transformações:**
- `http://www.w3.org/2000/09/xmldsig#enveloped-signature` (remove a assinatura do cálculo do digest).
- C14N padrão.
- **Algoritmo de Digest:** SHA-256 (`http://www.w3.org/2001/04/xmlenc#sha256`).
- **Algoritmo de Assinatura:** RSA-SHA256 (`http://www.w3.org/2001/04/xmldsig-more#rsa-sha256`).
- **KeyInfo:** Informações do certificado X.509 embutidas em `<X509Data><X509Certificate>`.
```xml
<ds:Signature Id="sig-[UUID]" xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:SignedInfo>
<ds:CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/>
<ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/>
<!-- Referência 0: Dados de Negócio (Documento completo ou elemento específico) -->
<ds:Reference URI="#[ElementoId]">
<ds:Transforms>
<ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/>
<ds:Transform Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/>
</ds:Transforms>
<ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
<ds:DigestValue>[Digest do nó em Base64]</ds:DigestValue>
</ds:Reference>
<!-- Referência 1: Propriedades Qualificadas Assinadas XAdES -->
<ds:Reference URI="#signed-props-[UUID]" Type="http://uri.etsi.org/01903#SignedProperties">
<ds:Transforms>
<ds:Transform Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/>
</ds:Transforms>
<ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
<ds:DigestValue>[Digest das SignedProperties em Base64]</ds:DigestValue>
</ds:Reference>
</ds:SignedInfo>
<ds:SignatureValue>[Assinatura RSA-SHA256 em Base64]</ds:SignatureValue>
<ds:KeyInfo>
<ds:X509Data>
<ds:X509Certificate>[Certificado DER do signatário em Base64]</ds:X509Certificate>
</ds:X509Data>
</ds:KeyInfo>
<ds:Object>
<xades:QualifyingProperties xmlns:xades="http://uri.etsi.org/01903/v1.3.2#" Target="#sig-[UUID]">
<xades:SignedProperties Id="signed-props-[UUID]" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" xmlns:xades="http://uri.etsi.org/01903/v1.3.2#">
<xades:SignedSignatureProperties>
<xades:SigningTime>2026-10-11T14:05:00Z</xades:SigningTime>
<xades:SigningCertificate>
<xades:Cert>
<xades:CertDigest>
<ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
<ds:DigestValue>[SHA-256 do Certificado]</ds:DigestValue>
</xades:CertDigest>
<xades:IssuerSerial>
<ds:X509IssuerName>[Emissor X.500]</ds:X509IssuerName>
<ds:X509SerialNumber>[Número Serial]</ds:X509SerialNumber>
</xades:IssuerSerial>
</xades:Cert>
</xades:SigningCertificate>
</xades:SignedSignatureProperties>
</xades:SignedProperties>
</xades:QualifyingProperties>
</ds:Object>
</ds:Signature>
```
---
## 3. Segurança e Mitigação contra XXE
## 3. Segurança e Mitigação contra Ataques XXE
O parser XML é configurado com blindagem estrita contra ataques **XML External Entity (XXE)** e expansões recursivas de entidades:
@@ -47,10 +108,11 @@ dbf.setExpandEntityReferences(false);
---
## 4. Validação Pós-Assinatura
## 4. Validação Pós-Assinatura e Auditoria
O serviço `XmlSigningService.validateXmlSignature(byte[] xmlBytes)` permite auditar a assinatura imediatamente após sua emissão, verificando:
O serviço `XmlSigningService.validateXmlSignature(byte[] xmlBytes)` e o `SignatureValidationService` realizam:
1. A integridade criptográfica da assinatura (`SignedInfo`).
2. A integridade dos digests de todas as referências apontadas (`Reference.validate()`).
3. O certificado X.509 público extraído da chave.
1. **Validação criptográfica de SignedInfo:** Confere se o `SignatureValue` foi emitido pela chave pública correspondente.
2. **Validação de integridade dos dados de negócio:** Confere o digest do conteúdo ou elemento assinado.
3. **Validação de integridade do XAdES:** Confere o digest das propriedades qualificadas (`SignedProperties`), rejeitando qualquer adulteração no horário de assinatura (`SigningTime`) ou no certificado declarado.
4. **Inspeção de signatário e datação:** Extração de nome, documento (CPF/CNPJ), autoridade certificadora emissora e datação auditável.
@@ -25,7 +25,8 @@ public record PdfSignatureOptions(
Integer targetPageNumber,
CpfDisplayMode cpfDisplayMode,
Boolean includeDateTime,
String customExtraInfo
String customExtraInfo,
Double customSealScale
) {
public PdfSignatureOptions {
@@ -51,6 +52,21 @@ public record PdfSignatureOptions(
if (includeDateTime == null) {
includeDateTime = sealTheme.defaultIncludeDateTime();
}
if (customSealScale == null || customSealScale <= 0) {
customSealScale = 1.0;
}
}
/**
* Construtor para compatibilidade com assinaturas anteriores a customSealScale.
*/
public PdfSignatureOptions(String certificateId, char[] pinOrPassword, boolean visibleSeal,
PdfSealTheme sealTheme, PdfSealPosition position, PdfSealPageChoice pageChoice,
String reason, String location, Float customX, Float customY,
Integer targetPageNumber, CpfDisplayMode cpfDisplayMode, Boolean includeDateTime,
String customExtraInfo) {
this(certificateId, pinOrPassword, visibleSeal, sealTheme, position, pageChoice, reason, location,
customX, customY, targetPageNumber, cpfDisplayMode, includeDateTime, customExtraInfo, 1.0);
}
/**
@@ -61,7 +77,7 @@ public record PdfSignatureOptions(
String reason, String location, Float customX, Float customY,
Integer targetPageNumber, CpfDisplayMode cpfDisplayMode, Boolean includeDateTime) {
this(certificateId, pinOrPassword, visibleSeal, sealTheme, position, pageChoice, reason, location,
customX, customY, targetPageNumber, cpfDisplayMode, includeDateTime, null);
customX, customY, targetPageNumber, cpfDisplayMode, includeDateTime, null, 1.0);
}
/**
@@ -70,7 +86,7 @@ public record PdfSignatureOptions(
public PdfSignatureOptions(String certificateId, char[] pinOrPassword, boolean visibleSeal,
PdfSealTheme sealTheme, PdfSealPosition position, PdfSealPageChoice pageChoice,
String reason, String location) {
this(certificateId, pinOrPassword, visibleSeal, sealTheme, position, pageChoice, reason, location, null, null, null, null, null, null);
this(certificateId, pinOrPassword, visibleSeal, sealTheme, position, pageChoice, reason, location, null, null, null, null, null, null, 1.0);
}
public PdfSignatureOptions(String certificateId, char[] pinOrPassword, boolean visibleSeal,
@@ -143,6 +159,7 @@ public record PdfSignatureOptions(
}
private String customExtraInfo;
private Double customSealScale = 1.0;
public Builder includeDateTime(boolean include) {
this.includeDateTime = include;
@@ -154,6 +171,11 @@ public record PdfSignatureOptions(
return this;
}
public Builder customSealScale(Double scale) {
this.customSealScale = scale;
return this;
}
public Builder customPosition(Float x, Float y, Integer pageNumber) {
this.customX = x;
this.customY = y;
@@ -179,7 +201,8 @@ public record PdfSignatureOptions(
targetPageNumber,
cpfDisplayMode,
includeDateTime,
customExtraInfo
customExtraInfo,
customSealScale
);
}
}
@@ -1,5 +1,6 @@
package br.com.cybercore.signer.core.pdf;
import br.com.cybercore.signer.core.crypto.X509CertificateUtils;
import br.com.cybercore.signer.core.domain.ManagedCertificate;
import br.com.cybercore.signer.core.domain.SigningKeyMaterial;
import br.com.cybercore.signer.core.pdf.seal.PdfSealData;
@@ -12,10 +13,19 @@ 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.PDResources;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.common.PDStream;
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceDictionary;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceStream;
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.PDSignature;
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.SignatureInterface;
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.SignatureOptions;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField;
import org.apache.pdfbox.rendering.PDFRenderer;
import org.bouncycastle.cert.jcajce.JcaCertStore;
import org.bouncycastle.cms.CMSProcessableByteArray;
@@ -172,6 +182,10 @@ public class PdfSigningService {
String issuerCn = extractIssuerCn(cert.issuerDn());
String signingTimeStr = DATE_TIME_FORMATTER.format(Instant.now());
// Extrai o nome amigável do titular (removendo qualquer sufixo como :CPF ou :CNPJ para o nome do assinante)
String rawName = cert.commonName() != null ? cert.commonName() : cert.alias();
String signerName = X509CertificateUtils.extractHolderName(rawName);
MessageDigest sha256 = MessageDigest.getInstance("SHA-256");
byte[] pdfDigest = sha256.digest(pdfBytes);
String hashShort = bytesToHex(pdfDigest).substring(0, 16).toUpperCase();
@@ -183,7 +197,7 @@ public class PdfSigningService {
// 3. Monta dados do selo visual
PdfSealData sealData = new PdfSealData(
cert.commonName() != null ? cert.commonName() : cert.alias(),
signerName,
signerDoc,
signingTimeStr,
issuerCn,
@@ -195,92 +209,78 @@ public class PdfSigningService {
effectiveExtraInfo
);
// 4. Carrega o PDF e estampa o selo se solicitado
byte[] intermediatePdf;
if (options.visibleSeal()) {
byte[] sealPng = PdfSealRenderer.renderToPngBytes(theme, sealData);
intermediatePdf = applyVisualSealToPdf(pdfBytes, sealPng, theme, options);
} else {
intermediatePdf = pdfBytes;
}
// 5. Aplica a assinatura PAdES criptográfica via PDFBox e Bouncy Castle
// 4. Carrega o PDF original e aplica a assinatura PAdES via atualização incremental (Incremental Update),
// preservando intactas quaisquer assinaturas digitais pré-existentes. Em conformidade com a ISO 32000-1 / ETSI PAdES,
// o selo visual é associado diretamente ao widget da assinatura (/Widget /AP /N) sem alterar o stream /Contents
// das páginas existentes, garantindo que o validador do ITI aprove a verificação incremental sem indeterminação.
ByteArrayOutputStream finalOut = new ByteArrayOutputStream();
try (PDDocument doc = Loader.loadPDF(intermediatePdf)) {
try (PDDocument doc = Loader.loadPDF(pdfBytes)) {
PDSignature signature = new PDSignature();
signature.setFilter(PDSignature.FILTER_ADOBE_PPKLITE);
signature.setSubFilter(PDSignature.SUBFILTER_ADBE_PKCS7_DETACHED);
signature.setName(cert.commonName());
signature.setName(signerName);
signature.setLocation(options.location());
signature.setReason(options.reason());
signature.setSignDate(Calendar.getInstance());
SignatureInterface signer = new PadesBouncyCastleSigner(keyMaterial);
if (options.visibleSeal()) {
byte[] sealPng = PdfSealRenderer.renderToPngBytes(theme, sealData);
int targetPage = resolveTargetPage(doc, options);
PDRectangle rect = calculateSealRectangle(doc, targetPage, theme, options);
try (InputStream visualTemplate = createVisualSignatureTemplate(doc, targetPage, rect, sealPng);
SignatureOptions signatureOptions = new SignatureOptions()) {
signatureOptions.setVisualSignature(visualTemplate);
signatureOptions.setPage(targetPage);
doc.addSignature(signature, signer, signatureOptions);
doc.saveIncremental(finalOut);
}
} else {
doc.addSignature(signature, signer);
doc.saveIncremental(finalOut);
}
}
logger.info("Assinatura PAdES concluída com sucesso. Tamanho final: {} bytes", finalOut.size());
return finalOut.toByteArray();
}
private byte[] applyVisualSealToPdf(byte[] pdfBytes, byte[] sealPng, PdfSealTheme theme,
PdfSignatureOptions options) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (PDDocument doc = Loader.loadPDF(pdfBytes)) {
private int resolveTargetPage(PDDocument doc, PdfSignatureOptions options) {
int totalPages = doc.getNumberOfPages();
if (totalPages == 0) {
return pdfBytes;
if (totalPages <= 0) {
return 0;
}
PDImageXObject pdImage = PDImageXObject.createFromByteArray(doc, sealPng, "cybercore_seal");
float sealWidth = theme.width() * 0.72f;
float sealHeight = theme.height() * 0.72f;
float margin = 20f;
// Se coordenadas customizadas foram informadas (clique ou drag & drop na página)
if (options.customX() != null && options.customY() != null) {
int targetPageIdx = totalPages - 1;
if (options.targetPageNumber() != null && options.targetPageNumber() >= 1 && options.targetPageNumber() <= totalPages) {
targetPageIdx = options.targetPageNumber() - 1;
return options.targetPageNumber() - 1;
}
PDPage page = doc.getPage(targetPageIdx);
PDRectangle mediaBox = page.getMediaBox();
float pageW = mediaBox.getWidth();
float pageH = mediaBox.getHeight();
float x = Math.max(0, Math.min(options.customX(), pageW - sealWidth));
float y = Math.max(0, Math.min(options.customY(), pageH - sealHeight));
try (PDPageContentStream cs = new PDPageContentStream(doc, page, PDPageContentStream.AppendMode.APPEND, true, true)) {
cs.drawImage(pdImage, x, y, sealWidth, sealHeight);
}
doc.save(baos);
return baos.toByteArray();
}
// Caso contrário, usa posicionamento por enum (BOTTOM_RIGHT, etc.)
int startPage = 0;
int endPage = totalPages - 1;
if (options.pageChoice() == PdfSealPageChoice.FIRST_PAGE) {
endPage = 0;
} else if (options.pageChoice() == PdfSealPageChoice.LAST_PAGE) {
startPage = totalPages - 1;
return 0;
}
return totalPages - 1;
}
for (int i = startPage; i <= endPage; i++) {
PDPage page = doc.getPage(i);
private PDRectangle calculateSealRectangle(PDDocument doc, int pageIndex, PdfSealTheme theme, PdfSignatureOptions options) {
PDPage page = doc.getPage(pageIndex);
PDRectangle mediaBox = page.getMediaBox();
float pageW = mediaBox.getWidth();
float pageH = mediaBox.getHeight();
float scale = (options.customSealScale() != null && options.customSealScale() > 0)
? options.customSealScale().floatValue()
: 1.0f;
float sealWidth = (theme.width() * 0.72f) * scale;
float sealHeight = (theme.height() * 0.72f) * scale;
float margin = 20f;
float x;
float y;
if (options.customX() != null && options.customY() != null) {
x = Math.max(0, Math.min(options.customX(), pageW - sealWidth));
y = Math.max(0, Math.min(options.customY(), pageH - sealHeight));
} else {
switch (options.position()) {
case BOTTOM_LEFT -> {
x = margin;
@@ -299,15 +299,58 @@ public class PdfSigningService {
y = margin;
}
}
try (PDPageContentStream cs = new PDPageContentStream(doc, page, PDPageContentStream.AppendMode.APPEND, true, true)) {
cs.drawImage(pdImage, x, y, sealWidth, sealHeight);
}
}
return new PDRectangle(x, y, sealWidth, sealHeight);
}
/**
* Cria um documento template PAdES para o SignatureOptions do PDFBox, incorporando o selo visual
* como a Aparência Normal (/AP /N) da anotação da assinatura (PDAnnotationWidget).
* O stream /Contents da página do documento original permanece completamente inalterado.
*/
private InputStream createVisualSignatureTemplate(PDDocument srcDoc, int pageNum,
PDRectangle rect, byte[] sealPngBytes) throws IOException {
try (PDDocument doc = new PDDocument()) {
PDPage page = new PDPage(srcDoc.getPage(pageNum).getMediaBox());
doc.addPage(page);
PDAcroForm acroForm = new PDAcroForm(doc);
doc.getDocumentCatalog().setAcroForm(acroForm);
PDSignatureField signatureField = new PDSignatureField(acroForm);
PDAnnotationWidget widget = signatureField.getWidgets().get(0);
acroForm.getFields().add(signatureField);
acroForm.setSignaturesExist(true);
acroForm.setAppendOnly(true);
acroForm.getCOSObject().setDirect(true);
widget.setRectangle(rect);
PDStream stream = new PDStream(doc);
PDFormXObject form = new PDFormXObject(stream);
PDResources res = new PDResources();
form.setResources(res);
form.setFormType(1);
PDRectangle bbox = new PDRectangle(rect.getWidth(), rect.getHeight());
form.setBBox(bbox);
PDAppearanceDictionary appearance = new PDAppearanceDictionary();
appearance.getCOSObject().setDirect(true);
PDAppearanceStream appearanceStream = new PDAppearanceStream(form.getCOSObject());
appearance.setNormalAppearance(appearanceStream);
widget.setAppearance(appearance);
try (PDPageContentStream cs = new PDPageContentStream(doc, appearanceStream)) {
PDImageXObject img = PDImageXObject.createFromByteArray(doc, sealPngBytes, "cybercore_seal");
cs.drawImage(img, 0, 0, rect.getWidth(), rect.getHeight());
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
doc.save(baos);
return new ByteArrayInputStream(baos.toByteArray());
}
return baos.toByteArray();
}
private static String bytesToHex(byte[] bytes) {
@@ -15,6 +15,7 @@ import org.bouncycastle.util.Store;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NodeList;
import javax.xml.crypto.dsig.XMLSignature;
@@ -221,32 +222,59 @@ public final class SignatureValidationService {
}
/**
* Validação de assinaturas embutidas em documentos XML (XMLDSig).
* Validação de assinaturas embutidas em documentos XML (XAdES ou XMLDSig).
*/
private ValidationResult validateXml(byte[] xmlBytes, String fileName) {
try {
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
dbf.setNamespaceAware(true);
dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
dbf.setFeature("http://xml.org/sax/features/external-general-entities", false);
dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
Document doc = dbf.newDocumentBuilder().parse(new ByteArrayInputStream(xmlBytes));
// Registro de atributos Id no DOM
registerAllIdAttributes(doc.getDocumentElement());
NodeList nl = doc.getElementsByTagNameNS(XMLSignature.XMLNS, "Signature");
if (nl.getLength() == 0) {
nl = doc.getElementsByTagName("Signature");
}
if (nl.getLength() == 0) {
return new ValidationResult(
ValidationStatus.INVALID,
ValidationType.XML_DSIG,
"Nenhum elemento <Signature> XMLDSig encontrado no documento.",
"Nenhum elemento <Signature> XML encontrado no documento.",
List.of(),
null,
fileName
);
}
// Identificação de padrão XAdES
NodeList qpList = doc.getElementsByTagNameNS("http://uri.etsi.org/01903/v1.3.2#", "QualifyingProperties");
if (qpList.getLength() == 0) {
qpList = doc.getElementsByTagName("QualifyingProperties");
}
boolean isXades = qpList.getLength() > 0;
ValidationType valType = isXades ? ValidationType.XADES_XML : ValidationType.XML_DSIG;
XMLSignatureFactory fac = XMLSignatureFactory.getInstance("DOM");
List<ValidationSignerInfo> signers = new ArrayList<>();
boolean allValid = true;
for (int i = 0; i < nl.getLength(); i++) {
DOMValidateContext valContext = new DOMValidateContext(new KeyValueKeySelector(), nl.item(i));
Element sigElement = (Element) nl.item(i);
DOMValidateContext valContext = new DOMValidateContext(new KeyValueKeySelector(), sigElement);
// Registra nós com atributo Id no valContext
NodeList allNodes = doc.getElementsByTagName("*");
for (int n = 0; n < allNodes.getLength(); n++) {
if (allNodes.item(n) instanceof Element e && e.hasAttribute("Id")) {
valContext.setIdAttributeNS(e, null, "Id");
}
}
XMLSignature signature = fac.unmarshalXMLSignature(valContext);
boolean coreValidity = signature.validate(valContext);
@@ -254,22 +282,66 @@ public final class SignatureValidationService {
allValid = false;
}
// Extração de metadados do signatário a partir do KeyInfo
String signerName = "Signatário XML #" + (i + 1);
String identifier = "";
String issuer = isXades ? "ICP-Brasil / XAdES" : "Assinatura Enveloped XML";
String certSubject = "XML Signature Element";
if (signature.getKeyInfo() != null) {
for (Object kiContent : signature.getKeyInfo().getContent()) {
if (kiContent instanceof javax.xml.crypto.dsig.keyinfo.X509Data x509Data) {
for (Object o : x509Data.getContent()) {
if (o instanceof X509Certificate cert) {
certSubject = cert.getSubjectX500Principal().getName();
signerName = X509CertificateUtils.extractCommonName(certSubject);
String cpf = X509CertificateUtils.extractCpf(cert, signerName);
if (cpf != null) {
identifier = "CPF: " + cpf;
} else {
String cnpj = X509CertificateUtils.extractCnpj(cert, signerName);
if (cnpj != null) {
identifier = "CNPJ: " + cnpj;
}
}
issuer = X509CertificateUtils.extractCommonName(cert.getIssuerX500Principal().getName());
break;
}
}
}
}
}
// Data de assinatura (SigningTime se XAdES, senão data atual)
String dateStr = new SimpleDateFormat("dd/MM/yyyy HH:mm:ss").format(new Date());
if (isXades) {
NodeList stList = doc.getElementsByTagNameNS("http://uri.etsi.org/01903/v1.3.2#", "SigningTime");
if (stList.getLength() > 0) {
dateStr = stList.item(0).getTextContent();
}
}
signers.add(new ValidationSignerInfo(
"Signatário XML #" + (i + 1),
"",
"Assinatura Enveloped XML",
new SimpleDateFormat("dd/MM/yyyy HH:mm:ss").format(new Date()),
signerName,
identifier,
issuer,
dateStr,
coreValidity,
coreValidity,
signature.getSignedInfo().getSignatureMethod().getAlgorithm(),
"XMLDSig Element",
"Validade estrutural e criptográfica: " + (coreValidity ? "OK" : "FALHA")
certSubject,
(isXades ? "Padrão XAdES-BES (ETSI TS 101 903 / ICP-Brasil)" : "Padrão XMLDSig W3C")
+ " • " + (coreValidity ? "Assinatura íntegra" : "Integridade violada")
));
}
ValidationStatus status = allValid ? ValidationStatus.VALID : ValidationStatus.INVALID;
String msg = allValid ? "Documento XML assinado e válido conforme padrão W3C XML-DSig." : "Assinatura XML inválida ou com integridade violada.";
return new ValidationResult(status, ValidationType.XML_DSIG, msg, signers, null, fileName);
String msg = allValid
? (isXades
? "Documento XML assinado e íntegro no padrão avançado XAdES-BES (ICP-Brasil)."
: "Documento XML assinado e válido conforme padrão W3C XMLDSig.")
: "Assinatura XML inválida ou com integridade violada.";
return new ValidationResult(status, valType, msg, signers, null, fileName);
} catch (Exception e) {
logger.error("Erro ao validar XML: {}", e.getMessage(), e);
@@ -284,6 +356,23 @@ public final class SignatureValidationService {
}
}
private static void registerAllIdAttributes(Element root) {
if (root == null) return;
if (root.hasAttribute("Id")) {
root.setIdAttribute("Id", true);
} else if (root.hasAttribute("id")) {
root.setIdAttribute("id", true);
} else if (root.hasAttribute("ID")) {
root.setIdAttribute("ID", true);
}
NodeList children = root.getChildNodes();
for (int i = 0; i < children.getLength(); i++) {
if (children.item(i) instanceof Element child) {
registerAllIdAttributes(child);
}
}
}
/**
* Processa e valida criptograficamente os signatários de um pacote CMSSignedData.
*/
@@ -7,6 +7,7 @@ public enum ValidationType {
DETACHED_CMS("Assinatura Destacada (CAdES / PKCS#7)", false),
ATTACHED_CMS("Assinatura Incorporada (CMS / P7M)", true),
PADES_PDF("Assinatura Incorporada em PDF (PAdES)", true),
XADES_XML("Assinatura Incorporada em XML (XAdES / ICP-Brasil)", true),
XML_DSIG("Assinatura Incorporada em XML (XML-DSig)", true),
UNKNOWN("Formato Desconhecido", false);
@@ -3,30 +3,43 @@ package br.com.cybercore.signer.core.xml;
import java.util.Objects;
/**
* Parâmetros de configuração para a operação de assinatura digital XMLDSig.
* Parâmetros de configuração para a operação de assinatura digital XML (XAdES ou XMLDSig).
*/
public record XmlSignatureOptions(
String certificateId,
char[] pinOrPassword,
String targetElementTag,
String targetElementId,
String signatureAlgorithm
String signatureAlgorithm,
XmlSignatureProfile profile
) {
public static final String DEFAULT_ALGORITHM = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256";
public static final XmlSignatureProfile DEFAULT_PROFILE = XmlSignatureProfile.XADES_BES;
public XmlSignatureOptions {
Objects.requireNonNull(certificateId, "ID do certificado não pode ser nulo.");
if (signatureAlgorithm == null || signatureAlgorithm.isBlank()) {
signatureAlgorithm = DEFAULT_ALGORITHM;
}
if (profile == null) {
profile = DEFAULT_PROFILE;
}
}
public static XmlSignatureOptions wholeDocument(String certificateId, char[] pinOrPassword) {
return new XmlSignatureOptions(certificateId, pinOrPassword, null, null, DEFAULT_ALGORITHM);
return new XmlSignatureOptions(certificateId, pinOrPassword, null, null, DEFAULT_ALGORITHM, DEFAULT_PROFILE);
}
public static XmlSignatureOptions wholeDocument(String certificateId, char[] pinOrPassword, XmlSignatureProfile profile) {
return new XmlSignatureOptions(certificateId, pinOrPassword, null, null, DEFAULT_ALGORITHM, profile);
}
public static XmlSignatureOptions forElement(String certificateId, char[] pinOrPassword, String tag, String id) {
return new XmlSignatureOptions(certificateId, pinOrPassword, tag, id, DEFAULT_ALGORITHM);
return new XmlSignatureOptions(certificateId, pinOrPassword, tag, id, DEFAULT_ALGORITHM, DEFAULT_PROFILE);
}
public static XmlSignatureOptions forElement(String certificateId, char[] pinOrPassword, String tag, String id, XmlSignatureProfile profile) {
return new XmlSignatureOptions(certificateId, pinOrPassword, tag, id, DEFAULT_ALGORITHM, profile);
}
}
@@ -0,0 +1,37 @@
package br.com.cybercore.signer.core.xml;
/**
* Perfis de assinatura digital para documentos XML suportados pelo CyberCore Signer.
*/
public enum XmlSignatureProfile {
/**
* Padrão XAdES-BES (XML Advanced Electronic Signatures - Basic Electronic Signature).
* Em conformidade com a especificação ETSI TS 101 903 e padrão ICP-Brasil DOC-ICP-15.03 (AD-RB).
* Inclui propriedades qualificadas assinadas: data/hora de assinatura (SigningTime)
* e referência criptográfica do certificado do signatário (SigningCertificate).
*/
XADES_BES("XAdES-BES (Padrão ICP-Brasil / ETSI)", "Assinatura avançada com data/hora e certificado qualificado vinculados"),
/**
* Padrão W3C XMLDSig convencional (Enveloped Signature sem propriedades qualificadas XAdES).
* Mantido para compatibilidade com layouts fiscais estritos (ex: NF-e, CT-e, MDF-e SEFAZ legados).
*/
XMLDSIG("XMLDSig Clássico (W3C)", "Assinatura enveloped básica sem propriedades qualificadas");
private final String displayName;
private final String description;
XmlSignatureProfile(String displayName, String description) {
this.displayName = displayName;
this.description = description;
}
public String getDisplayName() {
return displayName;
}
public String getDescription() {
return description;
}
}
@@ -17,12 +17,14 @@ import javax.xml.crypto.KeySelector;
import javax.xml.crypto.KeySelectorException;
import javax.xml.crypto.KeySelectorResult;
import javax.xml.crypto.XMLCryptoContext;
import javax.xml.crypto.XMLStructure;
import javax.xml.crypto.dom.DOMStructure;
import javax.xml.crypto.dsig.CanonicalizationMethod;
import javax.xml.crypto.dsig.DigestMethod;
import javax.xml.crypto.dsig.Reference;
import javax.xml.crypto.dsig.SignatureMethod;
import javax.xml.crypto.dsig.SignedInfo;
import javax.xml.crypto.dsig.Transform;
import javax.xml.crypto.dsig.XMLObject;
import javax.xml.crypto.dsig.XMLSignature;
import javax.xml.crypto.dsig.XMLSignatureFactory;
import javax.xml.crypto.dsig.dom.DOMSignContext;
@@ -43,25 +45,41 @@ import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.security.Key;
import java.security.MessageDigest;
import java.security.PublicKey;
import java.security.cert.X509Certificate;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
/**
* Serviço de alto nível para Assinatura Digital de Documentos XML segundo o padrão W3C XMLDSig e ICP-Brasil.
* Suporta assinatura enveloped de documento completo e assinatura de elementos por ID (NF-e, SPED, etc.).
* Serviço de alto nível para Assinatura Digital de Documentos XML segundo os padrões:
* - XAdES-BES (ETSI TS 101 903 / ICP-Brasil DOC-ICP-15.03 - Padrão Avançado)
* - W3C XMLDSig convencional (Enveloped / NF-e / SPED legado)
*
* Utiliza o suporte de fábrica nativo e seguro da plataforma Java (javax.xml.crypto.dsig).
*/
public class XmlSigningService {
private static final Logger logger = LoggerFactory.getLogger(XmlSigningService.class);
public static final String XADES_132_NS = "http://uri.etsi.org/01903/v1.3.2#";
public static final String XADES_SIGNED_PROPERTIES_TYPE = "http://uri.etsi.org/01903#SignedProperties";
public static final String XMLDSIG_NS = XMLSignature.XMLNS;
private static final DateTimeFormatter ISO_INSTANT_FORMATTER =
DateTimeFormatter.ofPattern("uuuu-MM-dd'T'HH:mm:ss'Z'").withZone(ZoneOffset.UTC);
private final CertificateManager certificateManager;
public XmlSigningService(CertificateManager certificateManager) {
@@ -85,7 +103,7 @@ public class XmlSigningService {
try (FileOutputStream fos = new FileOutputStream(targetXml)) {
fos.write(signedBytes);
}
logger.info("XML assinado com sucesso e salvo em: {}", targetXml.getAbsolutePath());
logger.info("XML assinado com sucesso ({}) e salvo em: {}", options.profile().getDisplayName(), targetXml.getAbsolutePath());
}
/**
@@ -117,7 +135,8 @@ public class XmlSigningService {
}
SigningKeyMaterial keyMaterial = keyMaterialOpt.get();
logger.info("Iniciando assinatura XMLDSig com certificado '{}' [tipo={}]", cert.commonName(), cert.type());
logger.info("Iniciando assinatura XML [perfil={}] com certificado '{}' [tipo={}]",
options.profile(), cert.commonName(), cert.type());
// 2. Parse Seguro do XML (Prevenção rigorosa de XXE)
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
@@ -134,13 +153,13 @@ public class XmlSigningService {
// 3. Configuração do XMLSignatureFactory
XMLSignatureFactory fac = XMLSignatureFactory.getInstance("DOM");
// Transformações padrão: Enveloped + C14N
List<Transform> transforms = new ArrayList<>();
transforms.add(fac.newTransform(Transform.ENVELOPED, (TransformParameterSpec) null));
transforms.add(fac.newTransform(CanonicalizationMethod.INCLUSIVE, (TransformParameterSpec) null));
// Transformações padrão para os dados de negócio: Enveloped + C14N
List<Transform> dataTransforms = new ArrayList<>();
dataTransforms.add(fac.newTransform(Transform.ENVELOPED, (TransformParameterSpec) null));
dataTransforms.add(fac.newTransform(CanonicalizationMethod.INCLUSIVE, (TransformParameterSpec) null));
// Determinação da Referência e Ponto de Inserção
Reference ref;
// Determinação da Referência de Dados e Ponto de Inserção da Assinatura
Reference dataRef;
Node insertionParent;
if (options.targetElementId() != null && !options.targetElementId().isBlank()) {
@@ -150,7 +169,7 @@ public class XmlSigningService {
throw new IllegalArgumentException("Elemento XML com ID '" + elemId + "' não encontrado no documento.");
}
targetElement.setIdAttribute("Id", true);
ref = fac.newReference("#" + elemId, fac.newDigestMethod(DigestMethod.SHA256, null), transforms, null, null);
dataRef = fac.newReference("#" + elemId, fac.newDigestMethod(DigestMethod.SHA256, null), dataTransforms, null, null);
insertionParent = (targetElement.getParentNode() != null) ? targetElement.getParentNode() : doc.getDocumentElement();
} else if (options.targetElementTag() != null && !options.targetElementTag().isBlank()) {
NodeList list = doc.getElementsByTagName(options.targetElementTag());
@@ -160,42 +179,87 @@ public class XmlSigningService {
if (list.getLength() > 0 && list.item(0) instanceof Element elem && elem.hasAttribute("Id")) {
String idVal = elem.getAttribute("Id");
elem.setIdAttribute("Id", true);
ref = fac.newReference("#" + idVal, fac.newDigestMethod(DigestMethod.SHA256, null), transforms, null, null);
dataRef = fac.newReference("#" + idVal, fac.newDigestMethod(DigestMethod.SHA256, null), dataTransforms, null, null);
insertionParent = (elem.getParentNode() != null) ? elem.getParentNode() : doc.getDocumentElement();
} else {
ref = fac.newReference("", fac.newDigestMethod(DigestMethod.SHA256, null), transforms, null, null);
dataRef = fac.newReference("", fac.newDigestMethod(DigestMethod.SHA256, null), dataTransforms, null, null);
insertionParent = doc.getDocumentElement();
}
} else {
// Assinatura Enveloped do documento completo
ref = fac.newReference("", fac.newDigestMethod(DigestMethod.SHA256, null), transforms, null, null);
dataRef = fac.newReference("", fac.newDigestMethod(DigestMethod.SHA256, null), dataTransforms, null, null);
insertionParent = doc.getDocumentElement();
}
// 4. Montagem do SignedInfo
List<Reference> references = new ArrayList<>();
references.add(dataRef);
List<XMLObject> xmlObjects = new ArrayList<>();
String signatureUniqueId = "sig-" + UUID.randomUUID();
Element signedPropertiesElem = null;
// 4. Se for perfil XAdES-BES, constrói as QualifyingProperties e a Reference correspondente
if (options.profile() == XmlSignatureProfile.XADES_BES) {
String signedPropsId = "signed-props-" + UUID.randomUUID();
signedPropertiesElem = buildXadesSignedProperties(doc, signedPropsId, keyMaterial.signingCertificate(), Instant.now());
Element qualifyingProperties = doc.createElementNS(XADES_132_NS, "xades:QualifyingProperties");
qualifyingProperties.setAttribute("Target", "#" + signatureUniqueId);
qualifyingProperties.appendChild(signedPropertiesElem);
XMLStructure xadesStructure = new DOMStructure(qualifyingProperties);
XMLObject xadesObject = fac.newXMLObject(Collections.singletonList(xadesStructure), null, null, null);
xmlObjects.add(xadesObject);
// Adiciona Referência Criptográfica cobrindo as SignedProperties
List<Transform> xadesTransforms = Collections.singletonList(
fac.newTransform(CanonicalizationMethod.INCLUSIVE, (TransformParameterSpec) null)
);
Reference xadesRef = fac.newReference(
"#" + signedPropsId,
fac.newDigestMethod(DigestMethod.SHA256, null),
xadesTransforms,
XADES_SIGNED_PROPERTIES_TYPE,
null
);
references.add(xadesRef);
}
// 5. Montagem do SignedInfo
SignedInfo si = fac.newSignedInfo(
fac.newCanonicalizationMethod(CanonicalizationMethod.INCLUSIVE, (C14NMethodParameterSpec) null),
fac.newSignatureMethod(options.signatureAlgorithm(), null),
Collections.singletonList(ref)
references
);
// 5. Inclusão da Cadeia de Certificados no KeyInfo
// 6. Inclusão da Cadeia de Certificados no KeyInfo
KeyInfoFactory kif = fac.getKeyInfoFactory();
List<Object> x509Content = new ArrayList<>();
x509Content.add(keyMaterial.signingCertificate());
X509Data x509Data = kif.newX509Data(x509Content);
KeyInfo ki = kif.newKeyInfo(Collections.singletonList(x509Data));
// 6. Execução da Assinatura DOM
// 7. Execução da Assinatura DOM
DOMSignContext dsc = new DOMSignContext(keyMaterial.privateKey(), insertionParent);
if (keyMaterial.jcaProvider() != null) {
dsc.setProperty("org.jcp.xml.dsig.internal.dom.SignatureProvider", keyMaterial.jcaProvider());
}
XMLSignature signature = fac.newXMLSignature(si, ki);
if (signedPropertiesElem != null) {
dsc.setIdAttributeNS(signedPropertiesElem, null, "Id");
}
XMLSignature signature = fac.newXMLSignature(
si,
ki,
xmlObjects,
signatureUniqueId,
null
);
signature.sign(dsc);
// 7. Serialização do XML Assinado
// 8. Serialização do XML Assinado
TransformerFactory tf = TransformerFactory.newInstance();
Transformer trans = tf.newTransformer();
trans.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "no");
@@ -205,12 +269,13 @@ public class XmlSigningService {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
trans.transform(new DOMSource(doc), new StreamResult(baos));
logger.info("Assinatura XMLDSig gerada com sucesso. Tamanho: {} bytes", baos.size());
logger.info("Assinatura XML ({}) gerada com sucesso. Tamanho final: {} bytes",
options.profile(), baos.size());
return baos.toByteArray();
}
/**
* Valida a integridade criptográfica de um documento XML assinado com XMLDSig.
* Valida a integridade criptográfica de um documento XML assinado com XAdES ou XMLDSig.
*/
public boolean validateXmlSignature(byte[] signedXmlBytes) throws Exception {
Objects.requireNonNull(signedXmlBytes, "Bytes do XML assinado não podem ser nulos.");
@@ -218,24 +283,17 @@ public class XmlSigningService {
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
dbf.setNamespaceAware(true);
dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
dbf.setFeature("http://xml.org/sax/features/external-general-entities", false);
dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
dbf.setXIncludeAware(false);
dbf.setExpandEntityReferences(false);
Document doc = dbf.newDocumentBuilder().parse(new ByteArrayInputStream(signedXmlBytes));
// Registra nós com atributo Id/id/ID para que o ResourceResolver do XMLDSig localize a referência
NodeList allElements = doc.getElementsByTagName("*");
for (int i = 0; i < allElements.getLength(); i++) {
if (allElements.item(i) instanceof Element e) {
if (e.hasAttribute("Id")) {
e.setIdAttribute("Id", true);
} else if (e.hasAttribute("id")) {
e.setIdAttribute("id", true);
} else if (e.hasAttribute("ID")) {
e.setIdAttribute("ID", true);
}
}
}
// Registra nós com atributo Id/id/ID para que o ResourceResolver localize as referências
registerIdAttributes(doc.getDocumentElement());
NodeList nl = doc.getElementsByTagNameNS(XMLSignature.XMLNS, "Signature");
NodeList nl = doc.getElementsByTagNameNS(XMLDSIG_NS, "Signature");
if (nl.getLength() == 0) {
nl = doc.getElementsByTagName("Signature");
}
@@ -248,8 +306,168 @@ public class XmlSigningService {
XMLSignatureFactory fac = XMLSignatureFactory.getInstance("DOM");
DOMValidateContext valContext = new DOMValidateContext(new X509KeySelector(), sigElement);
// Registra explicitamente elementos com atributo Id no valContext
NodeList allNodes = doc.getElementsByTagName("*");
for (int i = 0; i < allNodes.getLength(); i++) {
if (allNodes.item(i) instanceof Element e) {
if (e.hasAttribute("Id")) {
valContext.setIdAttributeNS(e, null, "Id");
}
}
}
XMLSignature signature = fac.unmarshalXMLSignature(valContext);
return signature.validate(valContext);
boolean coreValidity = signature.validate(valContext);
if (!coreValidity) {
boolean sv = signature.getSignatureValue().validate(valContext);
logger.warn("Validação XML falhou. SignatureValue válido: {}", sv);
int refIdx = 0;
for (Object o : signature.getSignedInfo().getReferences()) {
if (o instanceof Reference r) {
boolean rValid = r.validate(valContext);
byte[] calcDigest = r.getCalculatedDigestValue();
byte[] storedDigest = r.getDigestValue();
String calcB64 = calcDigest != null ? Base64.getEncoder().encodeToString(calcDigest) : "null";
String storedB64 = storedDigest != null ? Base64.getEncoder().encodeToString(storedDigest) : "null";
logger.warn("Reference[{}] URI='{}' válida: {} | calcDigest={} | storedDigest={}",
refIdx++, r.getURI(), rValid, calcB64, storedB64);
}
}
}
return coreValidity;
}
/**
* Inspeciona e extrai informações detalhadas da assinatura XML (XAdES ou XMLDSig).
*/
public XmlSignatureDetails inspectXmlSignature(byte[] signedXmlBytes) throws Exception {
Objects.requireNonNull(signedXmlBytes, "Bytes do XML assinado não podem ser nulos.");
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
dbf.setNamespaceAware(true);
dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
Document doc = dbf.newDocumentBuilder().parse(new ByteArrayInputStream(signedXmlBytes));
registerIdAttributes(doc.getDocumentElement());
NodeList nl = doc.getElementsByTagNameNS(XMLDSIG_NS, "Signature");
if (nl.getLength() == 0) {
nl = doc.getElementsByTagName("Signature");
}
if (nl.getLength() == 0) {
return null;
}
Element sigElem = (Element) nl.item(0);
String sigId = sigElem.getAttribute("Id");
// Verifica presença de XAdES
NodeList qpNodes = doc.getElementsByTagNameNS(XADES_132_NS, "QualifyingProperties");
boolean isXades = qpNodes.getLength() > 0;
String signingTime = null;
if (isXades) {
NodeList timeNodes = doc.getElementsByTagNameNS(XADES_132_NS, "SigningTime");
if (timeNodes.getLength() > 0) {
signingTime = timeNodes.item(0).getTextContent();
}
}
// Validação criptográfica
boolean isValid = false;
try {
isValid = validateXmlSignature(signedXmlBytes);
} catch (Exception ex) {
logger.warn("Validação da assinatura XML falhou durante inspeção: {}", ex.getMessage());
}
String sigMethod = "";
NodeList smNodes = sigElem.getElementsByTagNameNS(XMLDSIG_NS, "SignatureMethod");
if (smNodes.getLength() > 0 && smNodes.item(0) instanceof Element smElem) {
sigMethod = smElem.getAttribute("Algorithm");
}
return new XmlSignatureDetails(
isValid,
isXades ? XmlSignatureProfile.XADES_BES : XmlSignatureProfile.XMLDSIG,
sigMethod,
signingTime,
sigId
);
}
/**
* Constrói a estrutura DOM das propriedades assinadas do XAdES (SignedProperties).
*/
private Element buildXadesSignedProperties(Document doc, String signedPropsId, X509Certificate cert, Instant signInstant) throws Exception {
Element signedProperties = doc.createElementNS(XADES_132_NS, "xades:SignedProperties");
signedProperties.setAttributeNS("http://www.w3.org/2000/xmlns/", "xmlns:xades", XADES_132_NS);
signedProperties.setAttributeNS("http://www.w3.org/2000/xmlns/", "xmlns:ds", XMLDSIG_NS);
signedProperties.setAttribute("Id", signedPropsId);
signedProperties.setIdAttribute("Id", true);
Element signedSignatureProperties = doc.createElementNS(XADES_132_NS, "xades:SignedSignatureProperties");
// 1. Data e Hora UTC da Assinatura
Element signingTime = doc.createElementNS(XADES_132_NS, "xades:SigningTime");
signingTime.setTextContent(ISO_INSTANT_FORMATTER.format(signInstant));
signedSignatureProperties.appendChild(signingTime);
// 2. Vínculo Criptográfico do Certificado do Signatário (SigningCertificate)
Element signingCertificate = doc.createElementNS(XADES_132_NS, "xades:SigningCertificate");
Element certElem = doc.createElementNS(XADES_132_NS, "xades:Cert");
// Digest SHA-256 do Certificado DER
Element certDigest = doc.createElementNS(XADES_132_NS, "xades:CertDigest");
Element digestMethod = doc.createElementNS(XMLDSIG_NS, "ds:DigestMethod");
digestMethod.setAttribute("Algorithm", "http://www.w3.org/2001/04/xmlenc#sha256");
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] certHash = md.digest(cert.getEncoded());
String certHashB64 = Base64.getEncoder().encodeToString(certHash);
Element digestValue = doc.createElementNS(XMLDSIG_NS, "ds:DigestValue");
digestValue.setTextContent(certHashB64);
certDigest.appendChild(digestMethod);
certDigest.appendChild(digestValue);
certElem.appendChild(certDigest);
// Dados do Emissor e Serial do Certificado
Element issuerSerial = doc.createElementNS(XADES_132_NS, "xades:IssuerSerial");
Element x509IssuerName = doc.createElementNS(XMLDSIG_NS, "ds:X509IssuerName");
x509IssuerName.setTextContent(cert.getIssuerX500Principal().getName());
Element x509SerialNumber = doc.createElementNS(XMLDSIG_NS, "ds:X509SerialNumber");
x509SerialNumber.setTextContent(cert.getSerialNumber().toString());
issuerSerial.appendChild(x509IssuerName);
issuerSerial.appendChild(x509SerialNumber);
certElem.appendChild(issuerSerial);
signingCertificate.appendChild(certElem);
signedSignatureProperties.appendChild(signingCertificate);
signedProperties.appendChild(signedSignatureProperties);
return signedProperties;
}
private static void registerIdAttributes(Element root) {
if (root == null) return;
if (root.hasAttribute("Id")) {
root.setIdAttribute("Id", true);
} else if (root.hasAttribute("id")) {
root.setIdAttribute("id", true);
} else if (root.hasAttribute("ID")) {
root.setIdAttribute("ID", true);
}
NodeList children = root.getChildNodes();
for (int i = 0; i < children.getLength(); i++) {
if (children.item(i) instanceof Element child) {
registerIdAttributes(child);
}
}
}
private static Element findElementById(Document doc, String id, String hintTag) {
@@ -264,6 +482,17 @@ public class XmlSigningService {
return doc.getElementById(id);
}
/**
* DTO de detalhes de uma assinatura XML.
*/
public record XmlSignatureDetails(
boolean valid,
XmlSignatureProfile profile,
String algorithm,
String signingTime,
String signatureId
) {}
/**
* Seletor de chave pública baseado no certificado X.509 embutido no KeyInfo.
*/
@@ -18,6 +18,8 @@ import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
import org.bouncycastle.operator.ContentSigner;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import br.com.cybercore.signer.core.validation.SignatureValidationService;
import br.com.cybercore.signer.core.validation.ValidationResult;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@@ -41,6 +43,7 @@ class PdfSigningServiceTest {
private CertificateManager certificateManager;
private PdfSigningService pdfSigningService;
private String testCertId;
private String testCert2Id;
@BeforeEach
void setUp() throws Exception {
@@ -74,13 +77,29 @@ class PdfSigningServiceTest {
char[] pwd = "password123".toCharArray();
ks.setKeyEntry("test-cert", kp.getPrivate(), pwd, new Certificate[]{cert});
// Gera segundo par de chaves e certificado para co-assinatura
KeyPair kp2 = kpg.generateKeyPair();
X500Name subject2 = new X500Name("CN=Maria Souza:98765432100, O=CyberCore Testes, C=BR");
X509v3CertificateBuilder certBuilder2 = new JcaX509v3CertificateBuilder(
subject2,
BigInteger.valueOf(System.currentTimeMillis() + 100),
notBefore,
notAfter,
subject2,
kp2.getPublic()
);
ContentSigner signer2 = new JcaContentSignerBuilder("SHA256withRSA").build(kp2.getPrivate());
X509Certificate cert2 = new JcaX509CertificateConverter().getCertificate(certBuilder2.build(signer2));
ks.setKeyEntry("test-cert-2", kp2.getPrivate(), pwd, new Certificate[]{cert2});
Pkcs12CertificateProvider provider = new Pkcs12CertificateProvider("test-provider", "Provedor de Teste");
provider.setKeyStore(ks, pwd);
certificateManager.registerProvider(provider);
List<ManagedCertificate> list = certificateManager.discoverCertificates();
assertThat(list).isNotEmpty();
this.testCertId = list.get(0).id();
assertThat(list).hasSize(2);
this.testCertId = list.stream().filter(c -> c.alias().equals("test-cert")).findFirst().orElseThrow().id();
this.testCert2Id = list.stream().filter(c -> c.alias().equals("test-cert-2")).findFirst().orElseThrow().id();
this.pdfSigningService = new PdfSigningService(certificateManager);
}
@@ -215,4 +234,59 @@ class PdfSigningServiceTest {
byte[] pngBytes = PdfSigningService.renderPagePngBytes(originalPdf, 0, 72f);
assertThat(pngBytes).isNotEmpty();
}
@Test
@DisplayName("Deve manter ambas as assinaturas válidas ao assinar um PDF que já possui assinatura digital (Co-assinatura PAdES)")
void shouldKeepBothSignaturesValidWhenSigningAlreadySignedPdf() throws Exception {
byte[] originalPdf = createSamplePdf();
// 1ª Assinatura com o primeiro certificado
PdfSignatureOptions firstOptions = PdfSignatureOptions.builder(testCertId)
.visibleSeal(true)
.position(PdfSealPosition.BOTTOM_RIGHT)
.reason("Primeira Aprovação")
.location("Brasília, DF")
.build();
byte[] firstSignedPdf = pdfSigningService.signPdf(originalPdf, firstOptions);
assertThat(firstSignedPdf).isNotNull();
// 2ª Assinatura sobre o documento já assinado com o segundo certificado
PdfSignatureOptions secondOptions = PdfSignatureOptions.builder(testCert2Id)
.visibleSeal(true)
.position(PdfSealPosition.TOP_LEFT)
.reason("Segunda Aprovação Conjunta")
.location("São Paulo, SP")
.build();
byte[] doubleSignedPdf = pdfSigningService.signPdf(firstSignedPdf, secondOptions);
assertThat(doubleSignedPdf).isNotNull();
assertThat(doubleSignedPdf.length).isGreaterThan(firstSignedPdf.length);
// Validação estrutural das assinaturas no PDDocument
try (PDDocument doc = Loader.loadPDF(doubleSignedPdf)) {
List<PDSignature> signatures = doc.getSignatureDictionaries();
assertThat(signatures).hasSize(2);
assertThat(signatures.get(0).getName()).contains("Joao da Silva");
assertThat(signatures.get(0).getReason()).isEqualTo("Primeira Aprovação");
assertThat(signatures.get(1).getName()).contains("Maria Souza");
assertThat(signatures.get(1).getReason()).isEqualTo("Segunda Aprovação Conjunta");
}
// Validação criptográfica rigorosa via SignatureValidationService (ambas as assinaturas devem ser íntegras)
SignatureValidationService validationService = new SignatureValidationService();
ValidationResult validationResult = validationService.validateAttached(doubleSignedPdf, "duplo_assinado.pdf");
assertThat(validationResult.isValid()).isTrue();
assertThat(validationResult.signers()).hasSize(2);
// Ambas as assinaturas e digests devem ser 100% válidos
assertThat(validationResult.signers().get(0).signatureValid()).isTrue();
assertThat(validationResult.signers().get(0).digestValid()).isTrue();
assertThat(validationResult.signers().get(0).signerName()).contains("Joao da Silva");
assertThat(validationResult.signers().get(1).signatureValid()).isTrue();
assertThat(validationResult.signers().get(1).digestValid()).isTrue();
assertThat(validationResult.signers().get(1).signerName()).contains("Maria Souza");
}
}
@@ -72,8 +72,8 @@ class XmlSigningServiceTest {
}
@Test
@DisplayName("Deve assinar documento XML completo com XMLDSig Enveloped e validar com sucesso")
void shouldSignWholeXmlDocumentAndValidate() throws Exception {
@DisplayName("Deve assinar documento XML no padrão XAdES-BES por padrão e validar com sucesso")
void shouldSignWholeXmlDocumentWithXadesBesByDefaultAndValidate() throws Exception {
String xml = """
<?xml version="1.0" encoding="UTF-8"?>
<pedido id="ped-1001">
@@ -86,19 +86,38 @@ class XmlSigningServiceTest {
byte[] signedXmlBytes = xmlSigningService.signXml(xml.getBytes(StandardCharsets.UTF_8), options);
String signedXmlStr = new String(signedXmlBytes, StandardCharsets.UTF_8);
// Verifica tags essenciais do XMLDSig
assertThat(signedXmlStr).contains("<Signature");
assertThat(signedXmlStr).contains("<SignedInfo");
assertThat(signedXmlStr).contains("<SignatureValue");
assertThat(signedXmlStr).contains("<KeyInfo");
// Validação criptográfica
// Verifica propriedades qualificadas do XAdES-BES (ETSI TS 101 903 / ICP-Brasil)
assertThat(signedXmlStr).contains("QualifyingProperties");
assertThat(signedXmlStr).contains("SignedProperties");
assertThat(signedXmlStr).contains("SignedSignatureProperties");
assertThat(signedXmlStr).contains("SigningTime");
assertThat(signedXmlStr).contains("SigningCertificate");
assertThat(signedXmlStr).contains("CertDigest");
assertThat(signedXmlStr).contains("IssuerSerial");
assertThat(signedXmlStr).contains("Type=\"http://uri.etsi.org/01903#SignedProperties\"");
// Validação criptográfica do documento e das propriedades XAdES
boolean isValid = xmlSigningService.validateXmlSignature(signedXmlBytes);
assertThat(isValid).isTrue();
// Inspeção detalhada
XmlSigningService.XmlSignatureDetails details = xmlSigningService.inspectXmlSignature(signedXmlBytes);
assertThat(details).isNotNull();
assertThat(details.valid()).isTrue();
assertThat(details.profile()).isEqualTo(XmlSignatureProfile.XADES_BES);
assertThat(details.signingTime()).isNotBlank();
}
@Test
@DisplayName("Deve assinar nó XML específico com atributo Id (estilo NF-e) e validar")
void shouldSignSpecificXmlElementById() throws Exception {
@DisplayName("Deve assinar nó XML específico com atributo Id no padrão XAdES-BES e validar")
void shouldSignSpecificXmlElementWithXadesBes() throws Exception {
String nfeXml = """
<?xml version="1.0" encoding="UTF-8"?>
<NFe xmlns="http://www.portalfiscal.inf.br/nfe">
@@ -117,21 +136,48 @@ class XmlSigningServiceTest {
testCertId,
null,
"infNFe",
"NFe35261012345678000195550010000000011000000010"
"NFe35261012345678000195550010000000011000000010",
XmlSignatureProfile.XADES_BES
);
byte[] signedNfe = xmlSigningService.signXml(nfeXml.getBytes(StandardCharsets.UTF_8), options);
String signedStr = new String(signedNfe, StandardCharsets.UTF_8);
assertThat(signedStr).contains("URI=\"#NFe35261012345678000195550010000000011000000010\"");
assertThat(signedStr).contains("QualifyingProperties");
assertThat(signedStr).contains("SignedProperties");
boolean isValid = xmlSigningService.validateXmlSignature(signedNfe);
assertThat(isValid).isTrue();
}
@Test
@DisplayName("Deve detectar adulteração em XML assinado e reprovar na validação")
void shouldDetectTamperingInSignedXml() throws Exception {
@DisplayName("Deve assinar documento XML no perfil XMLDSig clássico quando explicitamente solicitado")
void shouldSignWithClassicXmlDsigWhenConfigured() throws Exception {
String xml = """
<?xml version="1.0" encoding="UTF-8"?>
<documento>
<campo>Valor de teste</campo>
</documento>
""";
XmlSignatureOptions options = XmlSignatureOptions.wholeDocument(testCertId, null, XmlSignatureProfile.XMLDSIG);
byte[] signedXmlBytes = xmlSigningService.signXml(xml.getBytes(StandardCharsets.UTF_8), options);
String signedXmlStr = new String(signedXmlBytes, StandardCharsets.UTF_8);
assertThat(signedXmlStr).contains("<Signature");
assertThat(signedXmlStr).doesNotContain("QualifyingProperties");
boolean isValid = xmlSigningService.validateXmlSignature(signedXmlBytes);
assertThat(isValid).isTrue();
XmlSigningService.XmlSignatureDetails details = xmlSigningService.inspectXmlSignature(signedXmlBytes);
assertThat(details.profile()).isEqualTo(XmlSignatureProfile.XMLDSIG);
}
@Test
@DisplayName("Deve detectar adulteração no conteúdo do XML assinado com XAdES")
void shouldDetectTamperingInSignedXmlContent() throws Exception {
String xml = """
<?xml version="1.0" encoding="UTF-8"?>
<contrato>
@@ -142,11 +188,36 @@ class XmlSigningServiceTest {
XmlSignatureOptions options = XmlSignatureOptions.wholeDocument(testCertId, null);
byte[] signedXml = xmlSigningService.signXml(xml.getBytes(StandardCharsets.UTF_8), options);
// Adulteração intencional do conteúdo original
// Adulteração no payload de negócio
String tampered = new String(signedXml, StandardCharsets.UTF_8)
.replace("R$ 100,00", "R$ 999.999,00");
boolean isValid = xmlSigningService.validateXmlSignature(tampered.getBytes(StandardCharsets.UTF_8));
assertThat(isValid).isFalse();
}
@Test
@DisplayName("Deve detectar adulteração nas propriedades qualificadas do XAdES (SigningTime)")
void shouldDetectTamperingInXadesSignedProperties() throws Exception {
String xml = """
<?xml version="1.0" encoding="UTF-8"?>
<contrato>
<clausula>Assinado conforme regras XAdES</clausula>
</contrato>
""";
XmlSignatureOptions options = XmlSignatureOptions.wholeDocument(testCertId, null);
byte[] signedXml = xmlSigningService.signXml(xml.getBytes(StandardCharsets.UTF_8), options);
String signedStr = new String(signedXml, StandardCharsets.UTF_8);
// Adulteração fraudulenta da data da assinatura no nó XAdES
String tampered = signedStr.replaceFirst(
"<xades:SigningTime>[^<]+</xades:SigningTime>",
"<xades:SigningTime>1999-01-01T00:00:00Z</xades:SigningTime>"
);
boolean isValid = xmlSigningService.validateXmlSignature(tampered.getBytes(StandardCharsets.UTF_8));
assertThat(isValid).isFalse();
}
}
@@ -386,7 +386,7 @@ public final class BatchSigningDialog {
Path dest = targetDir.resolve(outPdfName);
pdfService.signPdfFile(item.getFile(), dest.toFile(), options);
} else if (lowerName.endsWith(".xml")) {
// Assinatura XMLDSig de documento XML
// Assinatura XAdES-BES de documento XML (ETSI TS 101 903 / ICP-Brasil)
br.com.cybercore.signer.core.xml.XmlSigningService xmlService =
new br.com.cybercore.signer.core.xml.XmlSigningService(certManager);
br.com.cybercore.signer.core.xml.XmlSignatureOptions options =
@@ -1,5 +1,6 @@
package br.com.cybercore.signer.ui.view;
import br.com.cybercore.signer.core.crypto.X509CertificateUtils;
import br.com.cybercore.signer.core.domain.CertificateType;
import br.com.cybercore.signer.core.domain.ManagedCertificate;
import br.com.cybercore.signer.core.pdf.PdfSignatureOptions;
@@ -54,6 +55,22 @@ public final class PdfCertificateSignDialog {
private static final Logger logger = LoggerFactory.getLogger(PdfCertificateSignDialog.class);
public record SealConfig(
boolean visibleSeal,
PdfSealTheme sealTheme,
CpfDisplayMode cpfDisplayMode,
boolean includeDateTime,
String customExtraInfo,
PdfSignDialog.PositionOption positionOption,
PdfSignDialog.PageOption pageOption,
boolean isCustomPosition,
Float customX,
Float customY,
Integer customPageNumber,
String reason,
String location,
Double customSealScale
) {
public SealConfig(
boolean visibleSeal,
PdfSealTheme sealTheme,
CpfDisplayMode cpfDisplayMode,
@@ -67,7 +84,11 @@ public final class PdfCertificateSignDialog {
Integer customPageNumber,
String reason,
String location
) {}
) {
this(visibleSeal, sealTheme, cpfDisplayMode, includeDateTime, customExtraInfo, positionOption,
pageOption, isCustomPosition, customX, customY, customPageNumber, reason, location, 1.0);
}
}
private final Stage stage;
private final CertificateManager certManager;
@@ -196,8 +217,9 @@ public final class PdfCertificateSignDialog {
if (empty || item == null) {
setText(null);
} else {
String holderName = X509CertificateUtils.extractHolderName(item.commonName() != null ? item.commonName() : item.alias());
String doc = item.cpf() != null ? " [CPF: " + item.cpf() + "]" : (item.cnpj() != null ? " [CNPJ: " + item.cnpj() + "]" : "");
setText(item.commonName() + doc + " (" + item.type() + ")");
setText(holderName + doc + " (" + item.type() + ")");
}
}
});
@@ -208,8 +230,9 @@ public final class PdfCertificateSignDialog {
if (empty || item == null) {
setText(null);
} else {
String holderName = X509CertificateUtils.extractHolderName(item.commonName() != null ? item.commonName() : item.alias());
String doc = item.cpf() != null ? " [CPF: " + item.cpf() + "]" : (item.cnpj() != null ? " [CNPJ: " + item.cnpj() + "]" : "");
setText(item.commonName() + doc + " (" + item.type() + ")");
setText(holderName + doc + " (" + item.type() + ")");
}
}
});
@@ -429,7 +452,8 @@ public final class PdfCertificateSignDialog {
.includeDateTime(sealConfig.includeDateTime())
.customExtraInfo(sealConfig.customExtraInfo())
.reason(sealConfig.reason())
.location(sealConfig.location());
.location(sealConfig.location())
.customSealScale(sealConfig.customSealScale());
if (sealConfig.isCustomPosition() && sealConfig.customX() != null && sealConfig.customY() != null) {
b.customPosition(sealConfig.customX(), sealConfig.customY(), sealConfig.customPageNumber());
@@ -46,6 +46,8 @@ import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import javafx.scene.paint.Color;
import javafx.scene.shape.Circle;
import javafx.scene.shape.Rectangle;
import javafx.stage.FileChooser;
import javafx.stage.Modality;
import javafx.stage.Stage;
@@ -127,6 +129,24 @@ public final class PdfSignDialog {
private ComboBox<PageOption> pageCombo;
private ImageView sealPreviewImageView;
// Controle de Escala / Redimensionamento do Selo (1.0 = 100%)
private double sealScale = 1.0;
private static final double MIN_SEAL_SCALE = 0.40;
private static final double MAX_SEAL_SCALE = 1.80;
private boolean isResizeModeActive = false;
// Componentes do grupo do selo e alça interativa
private Pane sealGroupPane;
private Rectangle sealSelectionBorder;
private Circle resizeHandle;
private Button btnToggleResize;
private Button btnResetSealScale;
private Label lblSealScaleIndicator;
private double resizeStartSceneX;
private double resizeStartBaseWidth;
private boolean isResizingSealInProgress = false;
private long lastResizeActionTimestamp = 0;
// Visualizador Interativo da Folha
private ScrollPane sheetScroll;
private StackPane pageSheetContainer;
@@ -463,13 +483,34 @@ public final class PdfSignDialog {
zoomBox.getChildren().addAll(btnZoomOut, lblZoomValue, btnZoomIn, btnZoomFit);
// Grupo de Tamanho do Selo
HBox resizeToolBox = new HBox(6);
resizeToolBox.setAlignment(Pos.CENTER_LEFT);
resizeToolBox.setStyle("-fx-background-color: -fx-bg-primary; -fx-border-color: -fx-border-light; -fx-border-radius: 6px; -fx-padding: 2px 6px;");
btnToggleResize = new Button("📏 Redimensionar");
btnToggleResize.getStyleClass().addAll("btn-secondary", "btn-compact");
btnToggleResize.setTooltip(new Tooltip("Ativar/desativar alça interativa para alterar o tamanho do selo"));
btnToggleResize.setOnAction(e -> toggleResizeMode());
lblSealScaleIndicator = new Label("100%");
lblSealScaleIndicator.setStyle("-fx-font-size: 11px; -fx-font-weight: bold; -fx-min-width: 36px; -fx-alignment: center;");
btnResetSealScale = new Button("↺ 100%");
btnResetSealScale.getStyleClass().addAll("btn-secondary", "btn-compact");
btnResetSealScale.setTooltip(new Tooltip("Restaurar tamanho padrão original (100%)"));
btnResetSealScale.setVisible(false);
btnResetSealScale.setOnAction(e -> applySealScale(1.0));
resizeToolBox.getChildren().addAll(btnToggleResize, lblSealScaleIndicator, btnResetSealScale);
Region tbSpacer = new Region();
HBox.setHgrow(tbSpacer, Priority.ALWAYS);
lblPositionFeedback = new Label("👉 Clique na folha para posicionar");
lblPositionFeedback.setStyle("-fx-font-size: 11.5px; -fx-text-fill: -fx-accent; -fx-font-weight: bold;");
toolbar.getChildren().addAll(pageNavBox, zoomBox, tbSpacer, lblPositionFeedback);
toolbar.getChildren().addAll(pageNavBox, zoomBox, resizeToolBox, tbSpacer, lblPositionFeedback);
// ScrollPane da Folha de Papel
sheetScroll = new ScrollPane();
@@ -508,23 +549,61 @@ public final class PdfSignDialog {
interactiveOverlayPane = new Pane();
interactiveOverlayPane.setCursor(Cursor.CROSSHAIR);
interactiveOverlayPane.setOnMouseClicked(event -> {
if (sealDragOccurred) {
// Previne que a soltura do mouse no término do arraste cause clique residual
if (sealDragOccurred || isResizingSealInProgress || (System.currentTimeMillis() - lastResizeActionTimestamp < 400)) {
// Previne que a soltura do mouse no término do redimensionamento ou arraste cause clique residual
sealDragOccurred = false;
event.consume();
return;
}
if (isResizeModeActive) {
// No modo redimensionamento ativo, clicar fora não deve saltar o selo acidentalmente
event.consume();
return;
}
handleSheetClick(event);
});
// Grupo visual do selo composto pela imagem, borda de seleção e alça de quina
sealGroupPane = new Pane();
sealGroupPane.setVisible(false);
sealStampOverlayView = new ImageView();
sealStampOverlayView.setPreserveRatio(true);
sealStampOverlayView.setCursor(Cursor.MOVE);
DropShadow sealShadow = new DropShadow(8, 0, 2, Color.rgb(2, 132, 199, 0.65));
sealStampOverlayView.setEffect(sealShadow);
sealStampOverlayView.setVisible(false);
// Arraste com ancoragem precisa no ponto exato do clique dentro do selo (sem salto e sem drift)
sealSelectionBorder = new Rectangle();
sealSelectionBorder.setFill(Color.TRANSPARENT);
sealSelectionBorder.setStroke(Color.rgb(2, 132, 199));
sealSelectionBorder.setStrokeWidth(1.8);
sealSelectionBorder.getStrokeDashArray().addAll(5.0, 4.0);
sealSelectionBorder.setVisible(false);
sealSelectionBorder.setOnMousePressed(e -> e.consume());
sealSelectionBorder.setOnMouseDragged(e -> e.consume());
sealSelectionBorder.setOnMouseReleased(e -> e.consume());
sealSelectionBorder.setOnMouseClicked(e -> e.consume());
resizeHandle = new Circle(8);
resizeHandle.setFill(Color.rgb(2, 132, 199));
resizeHandle.setStroke(Color.WHITE);
resizeHandle.setStrokeWidth(2.0);
resizeHandle.setCursor(Cursor.SE_RESIZE);
DropShadow handleShadow = new DropShadow(4, 0, 1, Color.rgb(0, 0, 0, 0.35));
resizeHandle.setEffect(handleShadow);
resizeHandle.setVisible(false);
Tooltip.install(resizeHandle, new Tooltip("Puxe a quina para redimensionar o selo proporcionalmente"));
// Duplo clique no selo para ativar/desativar o modo de redimensionamento
sealStampOverlayView.setOnMouseClicked(event -> {
sealDragOccurred = false;
if (event.getClickCount() == 2) {
toggleResizeMode();
}
event.consume();
});
// Arraste com ancoragem precisa no ponto exato do clique dentro do selo
sealStampOverlayView.setOnMousePressed(event -> {
dragStartOffsetInSealX = event.getX();
dragStartOffsetInSealY = event.getY();
@@ -536,7 +615,6 @@ public final class PdfSignDialog {
if (!chkVisibleSeal.isSelected() || selectedPdfBytes == null) return;
sealDragOccurred = true;
// Converte coordenada da cena para coordenadas locais do painel da folha
Point2D localInSheet = interactiveOverlayPane.sceneToLocal(event.getSceneX(), event.getSceneY());
double targetX = localInSheet.getX() - dragStartOffsetInSealX;
double targetY = localInSheet.getY() - dragStartOffsetInSealY;
@@ -545,16 +623,47 @@ public final class PdfSignDialog {
});
sealStampOverlayView.setOnMouseReleased(event -> {
// Garante que o evento de soltura do mouse não borbulhe para a folha
lastResizeActionTimestamp = System.currentTimeMillis();
event.consume();
});
sealStampOverlayView.setOnMouseClicked(event -> {
sealDragOccurred = false;
// Eventos da Alça de Redimensionamento Interativa
resizeHandle.setOnMousePressed(event -> {
isResizingSealInProgress = true;
sealDragOccurred = true;
resizeStartSceneX = event.getSceneX();
resizeStartBaseWidth = currentBaseSealWidth * zoomFactor * sealScale;
event.consume();
});
interactiveOverlayPane.getChildren().add(sealStampOverlayView);
resizeHandle.setOnMouseDragged(event -> {
if (!chkVisibleSeal.isSelected() || selectedPdfBytes == null) return;
isResizingSealInProgress = true;
sealDragOccurred = true;
double deltaX = event.getSceneX() - resizeStartSceneX;
double newWidth = resizeStartBaseWidth + deltaX;
double baseW = currentBaseSealWidth * zoomFactor;
if (baseW > 0) {
applySealScalePreservingOrigin(newWidth / baseW);
}
event.consume();
});
resizeHandle.setOnMouseReleased(event -> {
isResizingSealInProgress = false;
lastResizeActionTimestamp = System.currentTimeMillis();
sealDragOccurred = true;
event.consume();
});
resizeHandle.setOnMouseClicked(event -> {
isResizingSealInProgress = false;
lastResizeActionTimestamp = System.currentTimeMillis();
event.consume();
});
sealGroupPane.getChildren().addAll(sealStampOverlayView, sealSelectionBorder, resizeHandle);
interactiveOverlayPane.getChildren().add(sealGroupPane);
pageSheetContainer.getChildren().addAll(pageImageView, interactiveOverlayPane);
sheetWrapper.getChildren().add(pageSheetContainer);
sheetScroll.setContent(sheetWrapper);
@@ -563,6 +672,98 @@ public final class PdfSignDialog {
return column;
}
private void toggleResizeMode() {
this.isResizeModeActive = !this.isResizeModeActive;
this.lastResizeActionTimestamp = System.currentTimeMillis();
if (sealSelectionBorder != null) {
sealSelectionBorder.setVisible(isResizeModeActive);
}
if (resizeHandle != null) {
resizeHandle.setVisible(isResizeModeActive);
}
if (btnToggleResize != null) {
if (isResizeModeActive) {
btnToggleResize.setText("✓ Concluir");
btnToggleResize.setStyle("-fx-background-color: #0284c7; -fx-text-fill: white; -fx-font-weight: bold;");
lblPositionFeedback.setText("👉 Puxe a alça azul no canto do selo para ajustar o tamanho");
} else {
btnToggleResize.setText("📏 Redimensionar");
btnToggleResize.setStyle("");
updateFeedbackLabel();
}
}
}
private void applySealScalePreservingOrigin(double newScale) {
if (!chkVisibleSeal.isSelected() || selectedPdfBytes == null) return;
double currentX = sealGroupPane != null ? sealGroupPane.getLayoutX() : 0;
double currentY = sealGroupPane != null ? sealGroupPane.getLayoutY() : 0;
this.sealScale = Math.max(MIN_SEAL_SCALE, Math.min(MAX_SEAL_SCALE, Math.round(newScale * 100.0) / 100.0));
if (lblSealScaleIndicator != null) {
lblSealScaleIndicator.setText(String.format("%.0f%%", sealScale * 100));
}
if (btnResetSealScale != null) {
btnResetSealScale.setVisible(Math.abs(sealScale - 1.0) > 0.02);
}
updateContainerZoomDimensions();
double sheetW = currentBaseSheetWidth * zoomFactor;
double sheetH = currentBaseSheetHeight * zoomFactor;
double sealW = currentBaseSealWidth * zoomFactor * sealScale;
double sealH = currentBaseSealHeight * zoomFactor * sealScale;
// Mantém a quina superior esquerda ancorada no mesmo ponto exato
double clampedX = Math.max(0, Math.min(currentX, sheetW - sealW));
double clampedY = Math.max(0, Math.min(currentY, sheetH - sealH));
if (sealGroupPane != null) {
sealGroupPane.setLayoutX(clampedX);
sealGroupPane.setLayoutY(clampedY);
sealGroupPane.setVisible(true);
}
this.sealRelativeX = (sheetW > 0) ? (clampedX / sheetW) : 0;
this.sealRelativeY = (sheetH > 0) ? (clampedY / sheetH) : 0;
PdfSealTheme theme = activeSealTheme != null ? activeSealTheme : PdfSealThemeRegistry.getDefaultTheme();
float basePdfW = (float) ((theme != null) ? theme.width() * 0.72 : 144.0);
float basePdfH = (float) ((theme != null) ? theme.height() * 0.72 : 44.0);
float scaledPdfW = basePdfW * (float) sealScale;
float scaledPdfH = basePdfH * (float) sealScale;
double scale = (currentPagePdfWidth > 0) ? (sheetW / currentPagePdfWidth) : 1.0;
float calculatedPdfX = (float) (clampedX / scale);
float calculatedPdfY = (float) (currentPagePdfHeight - (clampedY / scale) - scaledPdfH);
this.customPdfX = Math.max(0, Math.min(calculatedPdfX, currentPagePdfWidth - scaledPdfW));
this.customPdfY = Math.max(0, Math.min(calculatedPdfY, currentPagePdfHeight - scaledPdfH));
this.customPageNumber = currentPageIndex + 1;
this.isCustomPositionActive = true;
if (positionCombo.getValue() != PositionOption.CUSTOM) {
positionCombo.setValue(PositionOption.CUSTOM);
}
updateFeedbackLabel();
}
private void applySealScale(double newScale) {
this.sealScale = Math.max(MIN_SEAL_SCALE, Math.min(MAX_SEAL_SCALE, Math.round(newScale * 100.0) / 100.0));
if (lblSealScaleIndicator != null) {
lblSealScaleIndicator.setText(String.format("%.0f%%", sealScale * 100));
}
if (btnResetSealScale != null) {
btnResetSealScale.setVisible(Math.abs(sealScale - 1.0) > 0.02);
}
updateContainerZoomDimensions();
repositionSealByRelativeCoords();
updateFeedbackLabel();
}
private void adjustZoom(double delta) {
setZoom(Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, this.zoomFactor + delta)));
}
@@ -592,8 +793,19 @@ public final class PdfSignDialog {
pageImageView.setFitHeight(currentH);
}
if (sealStampOverlayView != null) {
sealStampOverlayView.setFitWidth(currentBaseSealWidth * zoomFactor);
sealStampOverlayView.setFitHeight(currentBaseSealHeight * zoomFactor);
double sealW = currentBaseSealWidth * zoomFactor * sealScale;
double sealH = currentBaseSealHeight * zoomFactor * sealScale;
sealStampOverlayView.setFitWidth(sealW);
sealStampOverlayView.setFitHeight(sealH);
if (sealSelectionBorder != null) {
sealSelectionBorder.setWidth(sealW);
sealSelectionBorder.setHeight(sealH);
}
if (resizeHandle != null) {
resizeHandle.setCenterX(sealW);
resizeHandle.setCenterY(sealH);
}
}
}
@@ -602,15 +814,17 @@ public final class PdfSignDialog {
double sheetW = currentBaseSheetWidth * zoomFactor;
double sheetH = currentBaseSheetHeight * zoomFactor;
double sealW = currentBaseSealWidth * zoomFactor;
double sealH = currentBaseSealHeight * zoomFactor;
double sealW = currentBaseSealWidth * zoomFactor * sealScale;
double sealH = currentBaseSealHeight * zoomFactor * sealScale;
double posX = Math.max(0, Math.min(sealRelativeX * sheetW, sheetW - sealW));
double posY = Math.max(0, Math.min(sealRelativeY * sheetH, sheetH - sealH));
sealStampOverlayView.setLayoutX(posX);
sealStampOverlayView.setLayoutY(posY);
sealStampOverlayView.setVisible(true);
if (sealGroupPane != null) {
sealGroupPane.setLayoutX(posX);
sealGroupPane.setLayoutY(posY);
sealGroupPane.setVisible(true);
}
}
// =========================================================================
@@ -653,7 +867,9 @@ public final class PdfSignDialog {
renderCurrentPage();
if (chkVisibleSeal.isSelected()) {
sealStampOverlayView.setVisible(true);
if (sealGroupPane != null) {
sealGroupPane.setVisible(true);
}
resetSealToCornerPosition(PositionOption.BOTTOM_RIGHT);
}
} catch (Exception ex) {
@@ -751,38 +967,42 @@ public final class PdfSignDialog {
// =========================================================================
private void handleSheetClick(MouseEvent event) {
if (!chkVisibleSeal.isSelected() || selectedPdfBytes == null) return;
double sealW = currentBaseSealWidth * zoomFactor;
double sealH = currentBaseSealHeight * zoomFactor;
double sealW = currentBaseSealWidth * zoomFactor * sealScale;
double sealH = currentBaseSealHeight * zoomFactor * sealScale;
updateSealPositionFromDrag(event.getX() - sealW / 2.0, event.getY() - sealH / 2.0);
}
private void updateSealPositionFromDrag(double newLayoutX, double newLayoutY) {
double sheetW = currentBaseSheetWidth * zoomFactor;
double sheetH = currentBaseSheetHeight * zoomFactor;
double sealW = currentBaseSealWidth * zoomFactor;
double sealH = currentBaseSealHeight * zoomFactor;
double sealW = currentBaseSealWidth * zoomFactor * sealScale;
double sealH = currentBaseSealHeight * zoomFactor * sealScale;
double clampedX = Math.max(0, Math.min(newLayoutX, sheetW - sealW));
double clampedY = Math.max(0, Math.min(newLayoutY, sheetH - sealH));
sealStampOverlayView.setLayoutX(clampedX);
sealStampOverlayView.setLayoutY(clampedY);
sealStampOverlayView.setVisible(true);
if (sealGroupPane != null) {
sealGroupPane.setLayoutX(clampedX);
sealGroupPane.setLayoutY(clampedY);
sealGroupPane.setVisible(true);
}
this.sealRelativeX = (sheetW > 0) ? (clampedX / sheetW) : 0;
this.sealRelativeY = (sheetH > 0) ? (clampedY / sheetH) : 0;
PdfSealTheme theme = activeSealTheme != null ? activeSealTheme : PdfSealThemeRegistry.getDefaultTheme();
float sealPdfW = (float) ((theme != null) ? theme.width() * 0.72 : 144.0);
float sealPdfH = (float) ((theme != null) ? theme.height() * 0.72 : 44.0);
float basePdfW = (float) ((theme != null) ? theme.width() * 0.72 : 144.0);
float basePdfH = (float) ((theme != null) ? theme.height() * 0.72 : 44.0);
float scaledPdfW = basePdfW * (float) sealScale;
float scaledPdfH = basePdfH * (float) sealScale;
// Conversão direta e exata da escala visual na tela para os pontos do PDF
double scale = (currentPagePdfWidth > 0) ? (sheetW / currentPagePdfWidth) : 1.0;
float calculatedPdfX = (float) (clampedX / scale);
float calculatedPdfY = (float) (currentPagePdfHeight - (clampedY / scale) - sealPdfH);
float calculatedPdfY = (float) (currentPagePdfHeight - (clampedY / scale) - scaledPdfH);
this.customPdfX = Math.max(0, Math.min(calculatedPdfX, currentPagePdfWidth - sealPdfW));
this.customPdfY = Math.max(0, Math.min(calculatedPdfY, currentPagePdfHeight - sealPdfH));
this.customPdfX = Math.max(0, Math.min(calculatedPdfX, currentPagePdfWidth - scaledPdfW));
this.customPdfY = Math.max(0, Math.min(calculatedPdfY, currentPagePdfHeight - scaledPdfH));
this.customPageNumber = currentPageIndex + 1;
this.isCustomPositionActive = true;
@@ -799,8 +1019,10 @@ public final class PdfSignDialog {
}
PdfSealTheme theme = activeSealTheme != null ? activeSealTheme : PdfSealThemeRegistry.getDefaultTheme();
float sealPdfW = (float) ((theme != null) ? theme.width() * 0.72 : 144.0);
float sealPdfH = (float) ((theme != null) ? theme.height() * 0.72 : 44.0);
float basePdfW = (float) ((theme != null) ? theme.width() * 0.72 : 144.0);
float basePdfH = (float) ((theme != null) ? theme.height() * 0.72 : 44.0);
float sealPdfW = basePdfW * (float) sealScale;
float sealPdfH = basePdfH * (float) sealScale;
float margin = 20f;
float targetPdfX;
@@ -853,16 +1075,22 @@ public final class PdfSignDialog {
private void updateFeedbackLabel() {
if (isCustomPositionActive && customPageNumber != null && customPdfX != null && customPdfY != null) {
lblPositionFeedback.setText(String.format("📍 Selo na Pág. %d [X: %.0f pt, Y: %.0f pt]",
customPageNumber, customPdfX, customPdfY));
String scaleStr = (Math.abs(sealScale - 1.0) > 0.02) ? String.format(" | Escala: %.0f%%", sealScale * 100) : "";
lblPositionFeedback.setText(String.format("📍 Selo na Pág. %d [X: %.0f pt, Y: %.0f pt]%s",
customPageNumber, customPdfX, customPdfY, scaleStr));
}
}
private void toggleVisibleSeal(boolean active) {
positionCombo.setDisable(!active);
pageCombo.setDisable(!active);
if (btnToggleResize != null) {
btnToggleResize.setDisable(!active);
}
sealPreviewImageView.setOpacity(active ? 1.0 : 0.3);
sealStampOverlayView.setVisible(active && selectedPdfBytes != null);
if (sealGroupPane != null) {
sealGroupPane.setVisible(active && selectedPdfBytes != null);
}
}
private void updateSealPreview() {
@@ -927,19 +1155,21 @@ public final class PdfSignDialog {
customPdfY,
customPageNumber,
DEFAULT_REASON,
DEFAULT_LOCATION
DEFAULT_LOCATION,
sealScale
);
String sealSummary;
if (chkVisibleSeal.isSelected()) {
String themeName = theme.name();
String scaleInfo = (Math.abs(sealScale - 1.0) > 0.02) ? String.format(" [Escala: %.0f%%]", sealScale * 100) : "";
if (isCustomPositionActive && customPageNumber != null && customPdfX != null && customPdfY != null) {
sealSummary = String.format("Selo Estampado (%s) — Pág. %d [X: %.0f pt, Y: %.0f pt]",
themeName, customPageNumber, customPdfX, customPdfY);
sealSummary = String.format("Selo Estampado (%s)%s — Pág. %d [X: %.0f pt, Y: %.0f pt]",
themeName, scaleInfo, customPageNumber, customPdfX, customPdfY);
} else {
sealSummary = String.format("Selo Estampado (%s) — %s (%s)",
themeName, (pos != null ? pos.label : "Canto Inferior Direito"), (pg != null ? pg.label : "Última Página"));
sealSummary = String.format("Selo Estampado (%s)%s — %s (%s)",
themeName, scaleInfo, (pos != null ? pos.label : "Canto Inferior Direito"), (pg != null ? pg.label : "Última Página"));
}
} else {
sealSummary = "Sem Selo Visual (Assinatura PAdES Invisível)";
@@ -5,6 +5,7 @@ import br.com.cybercore.signer.core.domain.ManagedCertificate;
import br.com.cybercore.signer.core.provider.CertificateManager;
import br.com.cybercore.signer.core.security.PinSessionManager;
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 br.com.cybercore.signer.ui.components.SvgIcons;
import br.com.cybercore.signer.ui.theme.ThemeManager;
@@ -25,6 +26,7 @@ import javafx.scene.control.PasswordField;
import javafx.scene.control.ProgressIndicator;
import javafx.scene.control.RadioButton;
import javafx.scene.control.ScrollPane;
import javafx.scene.control.Separator;
import javafx.scene.control.TextField;
import javafx.scene.control.ToggleGroup;
import javafx.scene.layout.ColumnConstraints;
@@ -68,7 +70,9 @@ public final class XmlSignDialog {
private PasswordField pinField;
private Label lblPinNotice;
// Controles de Modo de Assinatura
// Controles de Padrão e Modo de Assinatura
private RadioButton rbXadesBes;
private RadioButton rbXmlDsig;
private RadioButton rbWholeDocument;
private RadioButton rbSpecificElement;
private TextField txtElementTag;
@@ -94,10 +98,10 @@ public final class XmlSignDialog {
if (owner != null) {
this.stage.initOwner(owner);
}
this.stage.setTitle("CyberCore Signer — Assinatura Digital de XML (XMLDSig)");
this.stage.setTitle("CyberCore Signer — Assinatura Digital de XML (XAdES / ICP-Brasil)");
this.stage.setResizable(true);
this.stage.setMinWidth(720);
this.stage.setMinHeight(580);
this.stage.setMinHeight(600);
initLayout();
}
@@ -111,9 +115,9 @@ public final class XmlSignDialog {
HBox header = new HBox(14);
header.setAlignment(Pos.CENTER_LEFT);
VBox titleBox = new VBox(4);
Label lblTitle = new Label("ASSINATURA DE DOCUMENTO XML (XMLDSIG)");
Label lblTitle = new Label("ASSINATURA DE DOCUMENTO XML (XADES / ICP-BRASIL)");
lblTitle.getStyleClass().add("dialog-header");
Label lblSub = new Label("Assine digitalmente documentos XML no padrão W3C XMLDSig e ICP-Brasil (NF-e, CT-e, MDF-e, eSocial, etc.).");
Label lblSub = new Label("Assine digitalmente documentos XML no padrão avançado XAdES (ETSI TS 101 903 / ICP-Brasil DOC-ICP-15.03) ou XMLDSig.");
lblSub.getStyleClass().add("dialog-subtitle");
titleBox.getChildren().addAll(lblTitle, lblSub);
header.getChildren().addAll(SvgIcons.xmlIcon(), titleBox);
@@ -198,7 +202,21 @@ public final class XmlSignDialog {
// 3. Seção: Modo de Assinatura XMLDSig
VBox xmlModeSection = new VBox(12);
xmlModeSection.getStyleClass().add("dialog-box");
Label lblModeTitle = new Label("MODO DE ASSINATURA XMLDSIG");
Label lblProfileTitle = new Label("PADRÃO DE ASSINATURA XML");
lblProfileTitle.getStyleClass().add("dialog-field-title");
ToggleGroup profileGroup = new ToggleGroup();
rbXadesBes = new RadioButton("XAdES-BES (ETSI TS 101 903 / ICP-Brasil DOC-ICP-15.03 - Padrão Avançado)");
rbXadesBes.setToggleGroup(profileGroup);
rbXadesBes.setSelected(true);
rbXmlDsig = new RadioButton("XMLDSig Clássico W3C (Enveloped legado / NF-e SEFAZ)");
rbXmlDsig.setToggleGroup(profileGroup);
VBox profileBox = new VBox(8);
profileBox.getChildren().addAll(rbXadesBes, rbXmlDsig);
Label lblModeTitle = new Label("ESCOPO DE ASSINATURA");
lblModeTitle.getStyleClass().add("dialog-field-title");
ToggleGroup modeGroup = new ToggleGroup();
@@ -247,7 +265,16 @@ public final class XmlSignDialog {
chkValidateAfter = new CheckBox("Validar conformidade e integridade criptográfica após gravação");
chkValidateAfter.setSelected(true);
xmlModeSection.getChildren().addAll(lblModeTitle, rbWholeDocument, rbSpecificElement, elementConfigBox, chkValidateAfter);
xmlModeSection.getChildren().addAll(
lblProfileTitle,
profileBox,
new Separator(),
lblModeTitle,
rbWholeDocument,
rbSpecificElement,
elementConfigBox,
chkValidateAfter
);
formContent.getChildren().addAll(fileSection, certSection, xmlModeSection);
scrollPane.setContent(formContent);
@@ -389,6 +416,10 @@ public final class XmlSignDialog {
pin = pinField.getText().toCharArray();
}
XmlSignatureProfile profile = rbXadesBes.isSelected()
? XmlSignatureProfile.XADES_BES
: XmlSignatureProfile.XMLDSIG;
XmlSignatureOptions options;
if (rbSpecificElement.isSelected()) {
String tag = txtElementTag.getText().trim();
@@ -396,14 +427,14 @@ public final class XmlSignDialog {
if (tag.isBlank()) {
tag = "infNFe";
}
options = XmlSignatureOptions.forElement(cert.id(), pin, tag, id.isBlank() ? null : id);
options = XmlSignatureOptions.forElement(cert.id(), pin, tag, id.isBlank() ? null : id, profile);
} else {
options = XmlSignatureOptions.wholeDocument(cert.id(), pin);
options = XmlSignatureOptions.wholeDocument(cert.id(), pin, profile);
}
setFormDisabled(true);
progressIndicator.setVisible(true);
lblStatus.setText("Assinando documento XML (XMLDSig)...");
lblStatus.setText("Assinando documento XML (" + profile.getDisplayName() + ")...");
lblStatus.setStyle("-fx-text-fill: -fx-accent;");
boolean validate = chkValidateAfter.isSelected();