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
+121 -1
View File
@@ -29,7 +29,10 @@ O protocolo CCSP/1 adota o princípio de menor privilégio e segurança em profu
| `GET` | `/ccsp/v1/status` | Verifica integridade e status de prontidão do agente local | Nenhuma | | `GET` | `/ccsp/v1/status` | Verifica integridade e status de prontidão do agente local | Nenhuma |
| `POST` | `/ccsp/v1/authorize` | Handshake de autorização inicial de uma aplicação web | Consentimento visual do usuário | | `POST` | `/ccsp/v1/authorize` | Handshake de autorização inicial de uma aplicação web | Consentimento visual do usuário |
| `GET` | `/ccsp/v1/certificates` | Lista certificados digitais ICP-Brasil válidos e operacionais | Aplicação Autorizada | | `GET` | `/ccsp/v1/certificates` | Lista certificados digitais ICP-Brasil válidos e operacionais | Aplicação Autorizada |
| `POST` | `/ccsp/v1/sign` | Executa assinatura digital do digest informado | Aplicação Autorizada + Diálogo Nativo | | `GET` | `/ccsp/v1/seals` | Lista temas e estilos visuais de selos suportados pelo agente | Aplicação Autorizada |
| `POST` | `/ccsp/v1/sign` | Executa assinatura digital do digest informado (PAdES/CAdES/XMLDSig) | Aplicação Autorizada + Diálogo Nativo |
| `POST` | `/ccsp/v1/sign-pdf` | Assina documento PDF em Base64 com selo posicionado e devolve PDF assinado | Aplicação Autorizada + Diálogo Nativo |
| `POST` | `/ccsp/v1/sign-xml` | Assina documento XML (XAdES-BES / XMLDSig) e devolve XML assinado | Aplicação Autorizada + Diálogo Nativo |
| `POST` | `/ccsp/v1/authenticate` | Realiza login seguro via desafio criptográfico (*Challenge-Response*) | Aplicação Autorizada + Diálogo Nativo | | `POST` | `/ccsp/v1/authenticate` | Realiza login seguro via desafio criptográfico (*Challenge-Response*) | Aplicação Autorizada + Diálogo Nativo |
--- ---
@@ -214,6 +217,123 @@ Executa o login criptográfico de um usuário em sistemas web através do mecani
--- ---
### 3.6 `GET /ccsp/v1/seals`
Retorna a lista de temas e estilos de selos visuais embutidos e instalados no CyberCore Signer.
#### Cabeçalhos
- `Origin: https://meusistema.empresa.com.br`
- `X-CyberCore-AppId: 550e8400-e29b-41d4-a716-446655440000`
#### Resposta de Sucesso (`200 OK`)
```json
{
"version": "1",
"status": "success",
"default_seal_id": "icp-brasil",
"seals": [
{
"id": "icp-brasil",
"name": "ICP-Brasil Institucional",
"description": "Estilo oficial corporativo com escudo de segurança vetorial e dados completos do titular",
"primary_color": "#0d47a1",
"secondary_color": "#0284c7",
"border_color": "#93c5fd",
"background_color": "#ffffff",
"text_color": "#0f172a",
"icon_style": "shield",
"width": 320,
"height": 82,
"default_cpf_mode": "FULL",
"default_include_date_time": true
},
{
"id": "gov-br",
"name": "Governamental (Padrão Gov.br / ITI)",
"description": "Faixa verde e ouro com declaração expressa de validade pela MP nº 2.200-2/2001",
"primary_color": "#047857",
"secondary_color": "#eab308"
}
]
}
```
---
### 3.7 `POST /ccsp/v1/sign-pdf`
Recebe um documento PDF em Base64, a página e as coordenadas desejadas (`x`, `y`, `scale`), aplica o selo visual e a assinatura digital PAdES (ISO 32000-1 / RFC 5652) e retorna o PDF assinado completo em Base64.
#### Payload da Requisição
```json
{
"version": "1",
"request_id": "b1b74288-7264-44a5-9271-bf10d32c5f11",
"application_id": "550e8400-e29b-41d4-a716-446655440000",
"operation": "sign-pdf",
"certificate_hint": "cert-1",
"pdf": "JVBERi0xLjQKJ...Base64...",
"page": 1,
"x": 60.0,
"y": 60.0,
"scale": 1.0,
"visible_seal": true,
"seal_theme_id": "icp-brasil",
"reason": "Aprovado e assinado digitalmente",
"location": "Curitiba, PR",
"nonce": "c301d011-8012-4261-bcfa-e7912ab5a420"
}
```
> **Selo Customizado Inline (`seal_custom`):**
> Em vez de `seal_theme_id`, a requisição pode fornecer um objeto `seal_custom` com cores hexadecimais, título customizado, ícone vetorial (`shield`, `certificate`, `lock`) e política de privacidade LGPD (`FULL`, `MASKED`, `HIDDEN`).
#### Resposta de Sucesso (`200 OK`)
```json
{
"version": "1",
"request_id": "b1b74288-7264-44a5-9271-bf10d32c5f11",
"status": "signed",
"signed_pdf": "JVBERi0xLjQKJ...PDF_ASSINADO_BASE64...",
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"certificate": "MIIFazCCBF...Base64...",
"algorithm": "SHA256withRSA"
}
```
---
### 3.8 `POST /ccsp/v1/sign-xml`
Executa a assinatura digital de documentos XML em conformidade com o padrão XMLDSig W3C ou XAdES-BES ICP-Brasil (Padrão NF-e / NFC-e / CT-e / NFS-e). O documento assinado completo é devolvido diretamente ao sistema web.
#### Payload da Requisição
```json
{
"version": "1",
"request_id": "e44d32e1-455b-4357-9351-41c10d3b6f22",
"application_id": "550e8400-e29b-41d4-a716-446655440000",
"operation": "sign-xml",
"certificate_hint": "cert-1",
"xml": "<nfeProc versao=\"4.00\" xmlns=\"http://www.portalfiscal.inf.br/nfe\">...</nfeProc>",
"profile": "XADES_BES",
"target_element_tag": "infNFe",
"target_element_id": "NFe35261012345678000199550010000001001234567890",
"nonce": "d80b0322-8012-4261-bcfa-e7912ab5a420"
}
```
#### Resposta de Sucesso (`200 OK`)
```json
{
"version": "1",
"request_id": "e44d32e1-455b-4357-9351-41c10d3b6f22",
"status": "signed",
"signed_xml": "<nfeProc versao=\"4.00\" ...><Signature xmlns=\"http://www.w3.org/2000/09/xmldsig#\">...</Signature></nfeProc>",
"certificate": "MIIFazCCBF...Base64...",
"algorithm": "SHA256withRSA"
}
```
---
## 4. Estrutura Padrão de Erro e Códigos HTTP ## 4. Estrutura Padrão de Erro e Códigos HTTP
Quando qualquer operação falha, o servidor local CCSP/1 responde com um objeto JSON padronizado e o código de status HTTP correspondente: Quando qualquer operação falha, o servidor local CCSP/1 responde com um objeto JSON padronizado e o código de status HTTP correspondente:
+52
View File
@@ -90,3 +90,55 @@ A posição e a página da estampa são parametrizáveis via `PdfSignatureOption
- **Filtro:** `/Filter /Adobe.PPKLite` e `/SubFilter /adbe.pkcs7.detached`. - **Filtro:** `/Filter /Adobe.PPKLite` e `/SubFilter /adbe.pkcs7.detached`.
- **Algoritmo:** `SHA256withRSA` / PKCS#1 v1.5 com digest SHA-256. - **Algoritmo:** `SHA256withRSA` / PKCS#1 v1.5 com digest SHA-256.
- **Salva Incremental:** Preserva a integridade de assinaturas anteriores e do documento original sem quebrar certificações prévias. - **Salva Incremental:** Preserva a integridade de assinaturas anteriores e do documento original sem quebrar certificações prévias.
---
## 5. Ferramentas Web do SDK (@cybercore/signer-sdk)
O SDK web fornece componentes visuais nativos em JavaScript vanilla para aplicações web:
### 5.1 `PdfStampPicker` (Posicionador Visual de Selo)
Permite que o usuário final suba um PDF no navegador, folheie páginas, selecione cantos predefinidos ou arraste a estampa visual interativamente. O componente mapeia as coordenadas do canvas para o sistema de coordenadas do PDFBox (ISO 32000, com origem no canto inferior esquerdo) e gera em tempo real o payload JSON para a requisição.
```javascript
import { PdfStampPicker } from '@cybercore/signer-sdk';
const picker = new PdfStampPicker({
container: '#picker-container',
pdfSource: pdfArrayBuffer, // ou URL / Base64
initialScale: 1.0,
onPositionChange: (pos) => {
console.log('Página:', pos.page, 'X:', pos.x, 'Y:', pos.y);
}
});
// Obtém parâmetros prontos para a requisição CCSP
const stampConfig = picker.getStampConfig();
```
### 5.2 `PdfSealDesigner` (Criador & Personalizador de Selos)
Componente interativo com live preview em SVG vetorial para personalização de selos:
- Paleta de cores primária, secundária, fundo e texto.
- Ícones vetoriais (`shield`, `certificate`, `lock`) e upload de logotipo em Base64.
- Regras LGPD para mascaramento ou ocultação de CPF/CNPJ.
- Exportação da configuração JSON para envio inline via `seal_custom` na requisição `signPdfDocument()`.
### 5.3 Assinatura Completa via Base64 (`signPdfDocument`)
Permite enviar o PDF completo em Base64 e receber de volta o PDF assinado completo em Base64 para envio direto ao backend:
```javascript
const result = await signer.signPdfDocument({
certificateId: cert.id,
pdf: pdfArrayBuffer, // ou base64
page: stampConfig.page,
x: stampConfig.x,
y: stampConfig.y,
scale: stampConfig.scale,
seal_theme_id: 'icp-brasil', // ou seal_custom: customSealConfig
reason: 'Documento homologado',
location: 'São Paulo, SP'
});
console.log('PDF Assinado Base64:', result.signed_pdf);
console.log('SHA-256 do Documento Final:', result.sha256);
```
+24
View File
@@ -116,3 +116,27 @@ O serviço `XmlSigningService.validateXmlSignature(byte[] xmlBytes)` e o `Signat
2. **Validação de integridade dos dados de negócio:** Confere o digest do conteúdo ou elemento assinado. 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. 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. 4. **Inspeção de signatário e datação:** Extração de nome, documento (CPF/CNPJ), autoridade certificadora emissora e datação auditável.
---
## 5. Integração Web e SDK (@cybercore/signer-sdk)
As aplicações web autorizadas podem assinar arquivos XML diretamente via protocolo CCSP/1 com o método `signXmlDocument()`:
```javascript
import { CyberCoreSigner } from '@cybercore/signer-sdk';
const signer = new CyberCoreSigner();
// Assinatura de documento XML ou evento fiscal
const result = await signer.signXmlDocument({
certificateId: cert.id,
xml: xmlString, // texto puro ou xml_base64
profile: 'XADES_BES', // ou 'XMLDSIG'
targetElementTag: 'infNFe', // opcional
targetElementId: 'NFe35261012345678000199550010000001001234567890' // opcional
});
console.log('XML Assinado Retornado:', result.signed_xml);
console.log('Algoritmo Utilizado:', result.algorithm);
```
@@ -56,7 +56,67 @@ public record CcspRequest(
String purpose, String purpose,
@JsonProperty("challenge") @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 { ) implements Serializable {
public static final String PROTOCOL_VERSION = "1"; public static final String PROTOCOL_VERSION = "1";
@@ -68,7 +128,9 @@ public record CcspRequest(
) { ) {
this(version, requestId, applicationId, operation, algorithm, digest, this(version, requestId, applicationId, operation, algorithm, digest,
certificateHint, expiresAt, timestamp, nonce, signatureType, 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() { public boolean isVersionSupported() {
@@ -49,7 +49,16 @@ public record CcspResponse(
String documentName, String documentName,
@JsonProperty("document_type") @JsonProperty("document_type")
String documentType String documentType,
@JsonProperty("signed_pdf")
String signedPdf,
@JsonProperty("signed_xml")
String signedXml,
@JsonProperty("sha256")
String sha256
) implements Serializable { ) implements Serializable {
public CcspResponse { public CcspResponse {
@@ -88,7 +97,58 @@ public record CcspResponse(
subjectName, subjectName,
subjectCpfCnpj, subjectCpfCnpj,
documentName, 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, subjectName,
subjectCpfCnpj, subjectCpfCnpj,
null, null,
"AUTH_CHALLENGE" "AUTH_CHALLENGE",
null,
null,
null
); );
} }
@@ -126,6 +189,9 @@ public record CcspResponse(
null, null,
null, null,
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.crypto.X509CertificateUtils;
import br.com.cybercore.signer.core.domain.ManagedCertificate; import br.com.cybercore.signer.core.domain.ManagedCertificate;
import br.com.cybercore.signer.core.domain.SigningContext; 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 br.com.cybercore.signer.core.domain.SigningResult;
import java.security.MessageDigest; import java.security.MessageDigest;
import br.com.cybercore.signer.core.provider.CertificateManager; 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.Logger;
import org.slf4j.LoggerFactory; 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.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.io.OutputStream; import java.io.OutputStream;
@@ -56,6 +71,8 @@ public class CcspServer {
private final int port; private final int port;
private final CertificateManager certificateManager; private final CertificateManager certificateManager;
private final SigningService signingService; private final SigningService signingService;
private final PdfSigningService pdfSigningService;
private final XmlSigningService xmlSigningService;
private final ApplicationRegistry applicationRegistry; private final ApplicationRegistry applicationRegistry;
private final NonceManager nonceManager; private final NonceManager nonceManager;
private final OriginValidator originValidator; private final OriginValidator originValidator;
@@ -75,6 +92,8 @@ public class CcspServer {
this.port = port; this.port = port;
this.certificateManager = Objects.requireNonNull(certificateManager, "certificateManager cannot be null"); this.certificateManager = Objects.requireNonNull(certificateManager, "certificateManager cannot be null");
this.signingService = Objects.requireNonNull(signingService, "signingService 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.applicationRegistry = applicationRegistry != null ? applicationRegistry : ApplicationRegistry.getInstance();
this.promptListener = promptListener; this.promptListener = promptListener;
this.nonceManager = new NonceManager(); this.nonceManager = new NonceManager();
@@ -114,6 +133,9 @@ public class CcspServer {
httpServer.createContext("/ccsp/v1/sign", new SignHandler()); httpServer.createContext("/ccsp/v1/sign", new SignHandler());
httpServer.createContext("/ccsp/v1/authenticate", new SignHandler()); httpServer.createContext("/ccsp/v1/authenticate", new SignHandler());
httpServer.createContext("/ccsp/v1/authorize", new AuthorizeHandler()); 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(); httpServer.start();
running = true; 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 // 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.provider.ProviderType;
import br.com.cybercore.signer.core.security.ApplicationRegistry; import br.com.cybercore.signer.core.security.ApplicationRegistry;
import br.com.cybercore.signer.core.security.AuthorizedApplication; 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.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.api.io.TempDir;
import java.io.ByteArrayOutputStream;
import java.math.BigInteger;
import java.net.URI; import java.net.URI;
import java.net.http.HttpClient; import java.net.http.HttpClient;
import java.net.http.HttpRequest; import java.net.http.HttpRequest;
import java.net.http.HttpResponse; import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path; import java.nio.file.Path;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.security.cert.X509Certificate;
import java.time.Instant; import java.time.Instant;
import java.time.temporal.ChronoUnit; import java.time.temporal.ChronoUnit;
import java.util.Base64; import java.util.Base64;
import java.util.Date;
import java.util.List; import java.util.List;
import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
@@ -49,6 +70,28 @@ class CcspServerTest {
SigningService signingService = mock(SigningService.class); SigningService signingService = mock(SigningService.class);
ApplicationRegistry appRegistry = new ApplicationRegistry(tempDir.resolve("apps.json")); 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 // Registra uma aplicação de teste
appRegistry.register(new AuthorizedApplication( appRegistry.register(new AuthorizedApplication(
"app-test", "app-test",
@@ -64,8 +107,8 @@ class CcspServerTest {
"AliasTeste", "AliasTeste",
CertificateType.A1, CertificateType.A1,
CertificateStatus.AVAILABLE, CertificateStatus.AVAILABLE,
"CN=Teste ICP-Brasil, C=BR", "CN=Teste ICP-Brasil:12345678900, O=CyberCore Testes, C=BR",
"CN=AC Raiz, C=BR", "CN=Teste ICP-Brasil:12345678900, O=CyberCore Testes, C=BR",
"12345678", "12345678",
"a1b2c3", "a1b2c3",
"d4e5f6", "d4e5f6",
@@ -89,6 +132,9 @@ class CcspServerTest {
@Override public SigningResult sign(SigningContext context) { @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()); 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); certManager.registerProvider(dummyProvider);
@@ -149,7 +195,7 @@ class CcspServerTest {
HttpResponse<String> resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString()); HttpResponse<String> resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
assertThat(resp.statusCode()).isEqualTo(200); assertThat(resp.statusCode()).isEqualTo(200);
assertThat(resp.body()).contains("\"status\":\"success\""); assertThat(resp.body()).contains("\"status\":\"success\"");
assertThat(resp.body()).contains("CN=Teste ICP-Brasil, C=BR"); assertThat(resp.body()).contains("Teste ICP-Brasil");
} }
@Test @Test
@@ -320,4 +366,196 @@ class CcspServerTest {
assertThat(response.body()).contains("\"document_name\":\"proposta_comercial.pdf\""); assertThat(response.body()).contains("\"document_name\":\"proposta_comercial.pdf\"");
assertThat(response.body()).contains("\"document_type\":\"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");
}
} }
+421 -112
View File
@@ -7,6 +7,12 @@
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
<script>
if (window.pdfjsLib) {
window.pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
}
</script>
<style> <style>
:root { :root {
--bg-base: #090d16; --bg-base: #090d16;
@@ -615,10 +621,13 @@
<!-- Abas de Navegação --> <!-- Abas de Navegação -->
<div class="tabs-nav"> <div class="tabs-nav">
<button class="tab-btn active" data-tab="tab-pdf"> <button class="tab-btn active" data-tab="tab-pdf">
<span>📄</span> Assinar PDF <span>📄</span> Assinar PDF (Com Stamp Picker)
</button>
<button class="tab-btn" data-tab="tab-seals">
<span>🎨</span> Designer de Selos
</button> </button>
<button class="tab-btn" data-tab="tab-xml"> <button class="tab-btn" data-tab="tab-xml">
<span>📋</span> Assinar XML (NF-e) <span>📋</span> Assinar XML (XAdES / NF-e)
</button> </button>
<button class="tab-btn" data-tab="tab-file"> <button class="tab-btn" data-tab="tab-file">
<span>📁</span> Arquivos em Geral <span>📁</span> Arquivos em Geral
@@ -628,19 +637,19 @@
</button> </button>
</div> </div>
<!-- ABA 1: ASSINAR PDF --> <!-- ABA 1: ASSINAR PDF (INTERATIVO COM RETORNO EM BASE64) -->
<div id="tab-pdf" class="tab-pane active"> <div id="tab-pdf" class="tab-pane active">
<div class="card-grid"> <div class="card-grid" style="grid-template-columns: 1fr;">
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<span class="card-title"><span>📄</span> Seleção do Documento PDF</span> <span class="card-title"><span>📄</span> Seleção do Documento & Posicionador de Selo (Stamp Picker)</span>
<span class="badge-tag">PAdES / Digest</span> <span class="badge-tag">PAdES ISO 32000 / Base64</span>
</div> </div>
<div id="pdfDropZone" class="drop-zone"> <div id="pdfDropZone" class="drop-zone">
<div class="drop-icon">📑</div> <div class="drop-icon">📑</div>
<p style="font-size: 13px; font-weight: 600; color: #fff;">Arraste um PDF ou clique para selecionar</p> <p style="font-size: 13px; font-weight: 600; color: #fff;">Arraste um PDF ou clique para selecionar</p>
<p style="font-size: 11px; color: var(--text-muted);">Suporta qualquer documento .pdf</p> <p style="font-size: 11px; color: var(--text-muted);">O documento será renderizado abaixo para você escolher onde posicionar o selo</p>
<input type="file" id="pdfFileInput" accept="application/pdf" style="display: none;"> <input type="file" id="pdfFileInput" accept="application/pdf" style="display: none;">
</div> </div>
@@ -649,80 +658,157 @@
<strong id="pdfFileName" style="color: #fff;">contrato_prestacao.pdf</strong> <strong id="pdfFileName" style="color: #fff;">contrato_prestacao.pdf</strong>
<div id="pdfFileSize" style="color: var(--text-muted); font-size: 11px;">0 KB</div> <div id="pdfFileSize" style="color: var(--text-muted); font-size: 11px;">0 KB</div>
</div> </div>
<span class="badge-tag">PDF</span> <span class="badge-tag">PDF Carregado</span>
</div> </div>
<div class="form-group"> <!-- Contêiner do PdfStampPicker -->
<label class="form-label">Digest SHA-256 Calculado no Navegador (Base64):</label> <div id="stampPickerMount" style="margin-top: 14px; display: none;"></div>
<div id="pdfDigestBox" class="code-box">Selecione um arquivo PDF para calcular o digest...</div>
<!-- Configurações do Selo e da Assinatura -->
<div id="pdfOptionsSection" style="margin-top: 16px; display: none; background: var(--bg-surface); padding: 16px; border-radius: var(--radius-sm); border: 1px solid var(--border-subtle);">
<h4 style="font-size: 13px; font-weight: 700; color: #fff; margin-bottom: 12px;">🎨 Configurações do Selo Visual</h4>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 14px;">
<div class="form-group">
<label class="form-label">Tema do Selo do Agente:</label>
<select id="pdfSealThemeSelect" class="cert-bar-select">
<option value="icp-brasil">ICP-Brasil Institucional (Padrão)</option>
<option value="gov-br">Governamental (Gov.br / ITI)</option>
<option value="cybercore-modern">CyberCore Moderno</option>
<option value="minimal-clean">Minimalista Clássico</option>
<option value="custom">🎨 Selo Customizado do Designer</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Razão / Motivo da Assinatura:</label>
<input type="text" id="pdfReasonInput" value="Documento aprovado e assinado digitalmente" placeholder="Ex: De acordo com os termos">
</div>
<div class="form-group">
<label class="form-label">Localização:</label>
<input type="text" id="pdfLocationInput" value="Curitiba, PR - Brasil" placeholder="Ex: Brasília, DF">
</div>
</div>
<div style="margin-top: 16px; display: flex; gap: 10px; flex-wrap: wrap;">
<button id="btnSignPdfFull" class="btn btn-primary" style="flex: 2; padding: 12px; font-size: 13px;" disabled>
<span>✍️</span> Assinar PDF Completo no Agente (Devolve Base64)
</button>
<button id="btnSignPdfDigest" class="btn btn-secondary" style="flex: 1; padding: 12px; font-size: 12px;" disabled>
<span>🛡️</span> Assinar Apenas Digest (PAdES Remoto)
</button>
</div>
</div> </div>
<button id="btnSignPdf" class="btn btn-primary" style="padding: 12px;" disabled> <div id="pdfAlert" class="alert alert-info" style="display: none; margin-top: 14px;"></div>
<span>✍️</span> Assinar PDF com CyberCore
</button>
<div id="pdfAlert" class="alert alert-info" style="display: none;"></div>
</div> </div>
<div class="card"> <!-- Card de Resultado do PDF Assinado -->
<div id="pdfResultCard" class="card" style="display: none;">
<div class="card-header"> <div class="card-header">
<span class="card-title"><span>🛡️</span> Resultado da Assinatura Digital</span> <span class="card-title"><span>🎉</span> PDF Assinado Retornado com Sucesso</span>
<div style="display: flex; gap: 8px;">
<button id="btnDownloadPdf" class="btn btn-primary" style="padding: 6px 14px; font-size: 12px;">
<span>💾</span> Baixar PDF Assinado (.pdf)
</button>
<button id="btnCopyPdfBase64" class="btn btn-secondary" style="padding: 6px 12px; font-size: 12px;">
<span>📋</span> Copiar Base64
</button>
</div>
</div>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 14px; margin-bottom: 14px;">
<div class="form-group">
<label class="form-label">Status da Operação:</label>
<div id="pdfResultStatus" style="font-weight: 700; color: var(--success); font-size: 13px;">Assinado com Sucesso</div>
</div>
<div class="form-group">
<label class="form-label">Tamanho do PDF Assinado:</label>
<div id="pdfResultSize" style="font-family: var(--font-mono); font-size: 12px; color: #fff;">0 KB</div>
</div>
<div class="form-group">
<label class="form-label">SHA-256 do Documento Final:</label>
<div id="pdfResultSha256" style="font-family: var(--font-mono); font-size: 11px; color: var(--primary); word-break: break-all;">-</div>
</div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Assinatura Digital Retornada (Base64):</label> <label class="form-label">Conteúdo do PDF Assinado em Base64 (pronto para envio ao Backend):</label>
<div id="pdfSigOutput" class="code-box" style="min-height: 120px;">Aguardando assinatura do PDF...</div> <div id="pdfResultBase64" class="code-box" style="max-height: 140px;"></div>
</div>
<div class="form-group">
<label class="form-label">Cadeia do Certificado Digital X.509:</label>
<div id="pdfCertOutput" class="code-box">Nenhum certificado emitido ainda.</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- ABA 2: ASSINAR XML --> <!-- ABA 2: DESIGNER DE SELOS VISUAIS -->
<div id="tab-seals" class="tab-pane">
<div class="card-grid" style="grid-template-columns: 1fr;">
<div class="card">
<div class="card-header">
<span class="card-title"><span>🎨</span> Designer Interativo de Selos Visuais</span>
<span class="badge-tag">Customização em Tempo Real</span>
</div>
<p style="font-size: 13px; color: var(--text-muted); margin-bottom: 14px;">
Crie, personalize cores, tipografia, logotipos e metadados de selos visuais. As configurações geradas aqui podem ser enviadas diretamente na requisição de assinatura de PDF (<code style="color: var(--primary);">seal_custom</code>) ou salvas no sistema.
</p>
<div id="sealDesignerMount"></div>
</div>
</div>
</div>
<!-- ABA 3: ASSINAR XML -->
<div id="tab-xml" class="tab-pane"> <div id="tab-xml" class="tab-pane">
<div class="card-grid"> <div class="card-grid">
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<span class="card-title"><span>📋</span> Conteúdo do XML (NF-e / XMLDSig)</span> <span class="card-title"><span>📋</span> Conteúdo do XML (NF-e / CT-e / NFS-e)</span>
<button id="btnLoadXmlSample" class="btn btn-secondary" style="padding: 4px 10px; font-size: 11px;"> <button id="btnLoadXmlSample" class="btn btn-secondary" style="padding: 4px 10px; font-size: 11px;">
Carregar Exemplo NF-e Carregar Exemplo NF-e
</button> </button>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Estrutura XML para Assinatura:</label> <label class="form-label">Perfil de Assinatura XML:</label>
<textarea id="xmlContentInput" rows="7" placeholder="Cole o XML ou selecione um arquivo..."></textarea> <select id="xmlProfileSelect" class="cert-bar-select">
<option value="XADES_BES">XAdES-BES (Padrão Oficial ICP-Brasil / NF-e)</option>
<option value="XMLDSIG">XMLDSig W3C Padrão (Enveloped)</option>
</select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Digest SHA-256 Calculado no Navegador (Base64):</label> <label class="form-label">Estrutura XML para Assinatura:</label>
<div id="xmlDigestBox" class="code-box">Aguardando XML...</div> <textarea id="xmlContentInput" rows="8" placeholder="Cole o XML ou selecione um arquivo..."></textarea>
</div> </div>
<button id="btnSignXml" class="btn btn-primary" style="padding: 12px;" disabled> <div style="display: flex; gap: 10px; margin-top: 4px;">
<span>✍️</span> Assinar XML com CyberCore <button id="btnSignXmlFull" class="btn btn-primary" style="flex: 2; padding: 12px;" disabled>
</button> <span>✍️</span> Assinar XML Completo no Agente
</button>
<button id="btnSignXmlDigest" class="btn btn-secondary" style="flex: 1; padding: 12px; font-size: 12px;" disabled>
<span>🛡️</span> Apenas Digest
</button>
</div>
<div id="xmlAlert" class="alert alert-info" style="display: none;"></div> <div id="xmlAlert" class="alert alert-info" style="display: none; margin-top: 14px;"></div>
</div> </div>
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<span class="card-title"><span>🛡️</span> Pacote XML Assinado (XMLDSig)</span> <span class="card-title"><span>🛡️</span> Pacote XML Assinado</span>
<div style="display: flex; gap: 6px;">
<button id="btnDownloadXml" class="btn btn-primary" style="padding: 4px 10px; font-size: 11px; display: none;">
<span>💾</span> Baixar XML
</button>
<button id="btnCopyXml" class="btn btn-secondary" style="padding: 4px 10px; font-size: 11px; display: none;">
<span>📋</span> Copiar
</button>
</div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Bloco SignatureValue (Base64):</label> <label class="form-label">XML Assinado Completo (com envelope de assinatura):</label>
<div id="xmlSigOutput" class="code-box" style="min-height: 120px;">Aguardando assinatura do XML...</div> <div id="xmlSigOutput" class="code-box" style="min-height: 220px; max-height: 380px;">Aguardando submissão do XML para assinatura...</div>
</div>
<div class="form-group">
<label class="form-label">Tag XMLDSig Incorporável:</label>
<div id="xmlTagOutput" class="code-box">&lt;Signature xmlns="http://www.w3.org/2000/09/xmldsig#"&gt;...&lt;/Signature&gt;</div>
</div> </div>
</div> </div>
</div> </div>
@@ -851,7 +937,7 @@
<!-- Lógica Javascript com o SDK --> <!-- Lógica Javascript com o SDK -->
<script type="module"> <script type="module">
import { CyberCoreSigner } from '../src/signer-sdk.js'; import { CyberCoreSigner, PdfStampPicker, PdfSealDesigner } from '../src/signer-sdk.js';
const signer = new CyberCoreSigner({ const signer = new CyberCoreSigner({
baseUrl: 'http://127.0.0.1:21325', baseUrl: 'http://127.0.0.1:21325',
@@ -870,6 +956,16 @@
let currentCertificates = []; let currentCertificates = [];
let selectedCertificateId = null; let selectedCertificateId = null;
// Componentes e Estado
let pdfStampPicker = null;
let pdfSealDesigner = null;
let currentPdfFile = null;
let currentPdfBytes = null;
let currentPdfDigest = null;
let currentCustomSealConfig = null;
let currentSignedPdfBase64 = null;
let currentSignedXml = null;
// Gerenciador de Abas // Gerenciador de Abas
const tabButtons = document.querySelectorAll('.tab-btn'); const tabButtons = document.querySelectorAll('.tab-btn');
const tabPanes = document.querySelectorAll('.tab-pane'); const tabPanes = document.querySelectorAll('.tab-pane');
@@ -879,7 +975,13 @@
tabButtons.forEach(b => b.classList.remove('active')); tabButtons.forEach(b => b.classList.remove('active'));
tabPanes.forEach(p => p.classList.remove('active')); tabPanes.forEach(p => p.classList.remove('active'));
btn.classList.add('active'); btn.classList.add('active');
document.getElementById(btn.dataset.tab).classList.add('active'); const targetTab = btn.dataset.tab;
document.getElementById(targetTab).classList.add('active');
// Lazy-mount do Designer de Selos
if (targetTab === 'tab-seals' && !pdfSealDesigner) {
initSealDesigner();
}
}); });
}); });
@@ -900,6 +1002,29 @@
return false; return false;
} }
// Carregamento de Temas de Selos
async function loadSealThemes() {
try {
const themeSelect = document.getElementById('pdfSealThemeSelect');
const themes = await signer.getSealThemes();
if (themes && themes.length > 0) {
themeSelect.innerHTML = '';
themes.forEach(t => {
const opt = document.createElement('option');
opt.value = t.id;
opt.textContent = `${t.name} (${t.id})`;
themeSelect.appendChild(opt);
});
const customOpt = document.createElement('option');
customOpt.value = 'custom';
customOpt.textContent = '🎨 Selo Customizado do Designer';
themeSelect.appendChild(customOpt);
}
} catch (err) {
console.warn('Não foi possível carregar temas dinâmicos de selos:', err);
}
}
// Carregamento de Certificados // Carregamento de Certificados
async function loadCertificates() { async function loadCertificates() {
try { try {
@@ -913,7 +1038,7 @@
return; return;
} }
certs.forEach((c, index) => { certs.forEach((c) => {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = c.id; opt.value = c.id;
opt.textContent = `${c.common_name || c.alias} [${c.type}] (${c.device_name || c.provider_id})`; opt.textContent = `${c.common_name || c.alias} [${c.type}] (${c.device_name || c.provider_id})`;
@@ -949,10 +1074,24 @@
function updateButtonStates() { function updateButtonStates() {
const hasCert = Boolean(selectedCertificateId); const hasCert = Boolean(selectedCertificateId);
document.getElementById('btnSignPdf').disabled = !hasCert || !currentPdfDigest; const hasPdf = Boolean(currentPdfBytes);
document.getElementById('btnSignXml').disabled = !hasCert || !currentXmlDigest; const hasXml = Boolean(document.getElementById('xmlContentInput').value.trim());
document.getElementById('btnSignGen').disabled = !hasCert || !currentGenDigest;
document.getElementById('btnAuthLogin').disabled = !hasCert; const btnFull = document.getElementById('btnSignPdfFull');
const btnDigest = document.getElementById('btnSignPdfDigest');
if (btnFull) btnFull.disabled = !hasCert || !hasPdf;
if (btnDigest) btnDigest.disabled = !hasCert || !hasPdf;
const btnXmlFull = document.getElementById('btnSignXmlFull');
const btnXmlDigest = document.getElementById('btnSignXmlDigest');
if (btnXmlFull) btnXmlFull.disabled = !hasCert || !hasXml;
if (btnXmlDigest) btnXmlDigest.disabled = !hasCert || !hasXml;
const btnGen = document.getElementById('btnSignGen');
if (btnGen) btnGen.disabled = !hasCert || !currentGenDigest;
const btnAuth = document.getElementById('btnAuthLogin');
if (btnAuth) btnAuth.disabled = !hasCert;
} }
// Conexão e Handshake // Conexão e Handshake
@@ -964,6 +1103,7 @@
statusDot.classList.add('online'); statusDot.classList.add('online');
statusText.textContent = 'Conectado & Autorizado'; statusText.textContent = 'Conectado & Autorizado';
await loadCertificates(); await loadCertificates();
await loadSealThemes();
} catch (err) { } catch (err) {
alert('Erro ao conectar: ' + err.message); alert('Erro ao conectar: ' + err.message);
} finally { } finally {
@@ -976,25 +1116,31 @@
const online = await checkAgentStatus(); const online = await checkAgentStatus();
if (online) { if (online) {
await loadCertificates(); await loadCertificates();
await loadSealThemes();
} }
}); });
// ========================================================================= // =========================================================================
// ABA 1: LÓGICA DE PDF // ABA 1: LÓGICA DE PDF (STAMP PICKER & ASSINATURA BASE64)
// ========================================================================= // =========================================================================
const pdfDropZone = document.getElementById('pdfDropZone'); const pdfDropZone = document.getElementById('pdfDropZone');
const pdfFileInput = document.getElementById('pdfFileInput'); const pdfFileInput = document.getElementById('pdfFileInput');
const pdfFileMeta = document.getElementById('pdfFileMeta'); const pdfFileMeta = document.getElementById('pdfFileMeta');
const pdfFileName = document.getElementById('pdfFileName'); const pdfFileName = document.getElementById('pdfFileName');
const pdfFileSize = document.getElementById('pdfFileSize'); const pdfFileSize = document.getElementById('pdfFileSize');
const pdfDigestBox = document.getElementById('pdfDigestBox'); const stampPickerMount = document.getElementById('stampPickerMount');
const btnSignPdf = document.getElementById('btnSignPdf'); const pdfOptionsSection = document.getElementById('pdfOptionsSection');
const pdfSealThemeSelect = document.getElementById('pdfSealThemeSelect');
const btnSignPdfFull = document.getElementById('btnSignPdfFull');
const btnSignPdfDigest = document.getElementById('btnSignPdfDigest');
const pdfAlert = document.getElementById('pdfAlert'); const pdfAlert = document.getElementById('pdfAlert');
const pdfSigOutput = document.getElementById('pdfSigOutput');
const pdfCertOutput = document.getElementById('pdfCertOutput');
let currentPdfFile = null; const pdfResultCard = document.getElementById('pdfResultCard');
let currentPdfDigest = null; const pdfResultSize = document.getElementById('pdfResultSize');
const pdfResultSha256 = document.getElementById('pdfResultSha256');
const pdfResultBase64 = document.getElementById('pdfResultBase64');
const btnDownloadPdf = document.getElementById('btnDownloadPdf');
const btnCopyPdfBase64 = document.getElementById('btnCopyPdfBase64');
pdfDropZone.addEventListener('click', () => pdfFileInput.click()); pdfDropZone.addEventListener('click', () => pdfFileInput.click());
pdfFileInput.addEventListener('change', e => { pdfFileInput.addEventListener('change', e => {
@@ -1019,17 +1165,84 @@
pdfFileMeta.style.display = 'flex'; pdfFileMeta.style.display = 'flex';
const buffer = await file.arrayBuffer(); const buffer = await file.arrayBuffer();
currentPdfBytes = buffer;
currentPdfDigest = await signer._calculateSha256(buffer); currentPdfDigest = await signer._calculateSha256(buffer);
pdfDigestBox.textContent = `SHA-256 (Base64): ${currentPdfDigest}\nArquivo: ${file.name} (${file.size} bytes)`;
stampPickerMount.style.display = 'block';
pdfOptionsSection.style.display = 'block';
if (!pdfStampPicker) {
pdfStampPicker = new PdfStampPicker({
container: stampPickerMount,
pdfSource: buffer,
initialScale: 1.0,
onPositionChange: (pos) => {
console.log('Posição do selo atualizada:', pos);
}
});
} else {
pdfStampPicker.loadPdf(buffer);
}
updateButtonStates(); updateButtonStates();
} }
btnSignPdf.addEventListener('click', async () => { // Assinatura Completa de PDF (Devolve PDF Assinado Base64)
btnSignPdf.disabled = true; btnSignPdfFull.addEventListener('click', async () => {
btnSignPdf.innerHTML = '<span class="spinner"></span> Solicitando no Agente...'; btnSignPdfFull.disabled = true;
btnSignPdfFull.innerHTML = '<span class="spinner"></span> Assinando no Agente...';
pdfAlert.style.display = 'block'; pdfAlert.style.display = 'block';
pdfAlert.className = 'alert alert-info'; pdfAlert.className = 'alert alert-info';
pdfAlert.textContent = 'Confirme a assinatura na janela do CyberCore Signer aberta no seu computador...'; pdfAlert.textContent = 'Confirme a assinatura na janela do CyberCore Signer...';
try {
const stampConfig = pdfStampPicker ? pdfStampPicker.getStampConfig() : { page: 1, x: 50, y: 50, scale: 1.0 };
const selectedTheme = pdfSealThemeSelect.value;
const signParams = {
certificateId: selectedCertificateId,
pdf: currentPdfBytes,
page: stampConfig.page,
x: stampConfig.x,
y: stampConfig.y,
scale: stampConfig.scale,
reason: document.getElementById('pdfReasonInput').value || 'Assinado Digitalmente',
location: document.getElementById('pdfLocationInput').value || 'Brasil'
};
if (selectedTheme === 'custom' && currentCustomSealConfig) {
signParams.seal_custom = currentCustomSealConfig;
} else {
signParams.seal_theme_id = selectedTheme === 'custom' ? 'icp-brasil' : selectedTheme;
}
const result = await signer.signPdfDocument(signParams);
pdfAlert.className = 'alert alert-success';
pdfAlert.textContent = '✓ Documento PDF assinado com sucesso via PAdES (ISO 32000)!';
currentSignedPdfBase64 = result.signed_pdf;
pdfResultCard.style.display = 'block';
pdfResultSize.textContent = (result.signed_pdf.length * 0.75 / 1024).toFixed(1) + ' KB';
pdfResultSha256.textContent = result.sha256 || 'Calculado com sucesso';
pdfResultBase64.textContent = result.signed_pdf;
pdfResultCard.scrollIntoView({ behavior: 'smooth' });
} catch (err) {
pdfAlert.className = 'alert alert-error';
pdfAlert.textContent = 'Erro ao assinar PDF: ' + err.message;
} finally {
btnSignPdfFull.disabled = false;
btnSignPdfFull.innerHTML = '<span>✍️</span> Assinar PDF Completo no Agente (Devolve Base64)';
}
});
// Assinatura via Digest (Fluxo Tradicional)
btnSignPdfDigest.addEventListener('click', async () => {
btnSignPdfDigest.disabled = true;
btnSignPdfDigest.innerHTML = '<span class="spinner"></span> Solicitando...';
pdfAlert.style.display = 'block';
pdfAlert.className = 'alert alert-info';
pdfAlert.textContent = 'Assinando digest do PDF no CyberCore Signer...';
try { try {
const result = await signer.signPdf(currentPdfFile, { const result = await signer.signPdf(currentPdfFile, {
@@ -1038,30 +1251,97 @@
}); });
pdfAlert.className = 'alert alert-success'; pdfAlert.className = 'alert alert-success';
pdfAlert.textContent = `✓ Documento PDF assinado com sucesso via ${result.algorithm}!`; pdfAlert.textContent = `✓ Digest assinado com sucesso! Algoritmo: ${result.algorithm}`;
pdfSigOutput.textContent = result.signature;
pdfCertOutput.textContent = result.certificate;
} catch (err) { } catch (err) {
pdfAlert.className = 'alert alert-error'; pdfAlert.className = 'alert alert-error';
pdfAlert.textContent = 'Erro ao assinar PDF: ' + err.message; pdfAlert.textContent = 'Erro ao assinar digest: ' + err.message;
} finally { } finally {
btnSignPdf.disabled = false; btnSignPdfDigest.disabled = false;
btnSignPdf.innerHTML = '<span>✍️</span> Assinar PDF com CyberCore'; btnSignPdfDigest.innerHTML = '<span>🛡️</span> Assinar Apenas Digest (PAdES Remoto)';
} }
}); });
// Download do PDF Assinado
btnDownloadPdf.addEventListener('click', () => {
if (!currentSignedPdfBase64) return;
const blob = signer._base64ToBlob(currentSignedPdfBase64, 'application/pdf');
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
const origName = currentPdfFile ? currentPdfFile.name.replace(/\.pdf$/i, '') : 'documento';
a.href = url;
a.download = `${origName}_assinado.pdf`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
});
// Cópia do Base64 do PDF
btnCopyPdfBase64.addEventListener('click', async () => {
if (!currentSignedPdfBase64) return;
await navigator.clipboard.writeText(currentSignedPdfBase64);
const origText = btnCopyPdfBase64.innerHTML;
btnCopyPdfBase64.innerHTML = '<span>✓</span> Copiado!';
setTimeout(() => { btnCopyPdfBase64.innerHTML = origText; }, 2000);
});
// ========================================================================= // =========================================================================
// ABA 2: LÓGICA DE XML // ABA 2: DESIGNER DE SELOS VISUAIS
// =========================================================================
function initSealDesigner() {
const mount = document.getElementById('sealDesignerMount');
pdfSealDesigner = new PdfSealDesigner({
container: mount,
initialConfig: {
name: 'Selo Customizado',
title: 'ASSINADO DIGITALMENTE',
primary_color: '#0284c7',
secondary_color: '#10b981',
background_color: '#ffffff',
text_color: '#0f172a',
border_color: '#38bdf8',
icon_style: 'shield',
default_cpf_mode: 'MASKED',
default_include_date_time: true
},
onConfigChange: (cfg) => {
currentCustomSealConfig = cfg;
}
});
// Botão para aplicar o selo na aba de PDF
const applyBtnWrap = document.createElement('div');
applyBtnWrap.style.marginTop = '16px';
applyBtnWrap.style.display = 'flex';
applyBtnWrap.style.gap = '10px';
applyBtnWrap.innerHTML = `
<button id="btnApplySealToPdf" class="btn btn-primary" style="padding: 10px 18px; font-size: 13px;">
<span>🚀</span> Usar este Selo no Posicionador de PDF
</button>
`;
mount.appendChild(applyBtnWrap);
document.getElementById('btnApplySealToPdf').addEventListener('click', () => {
currentCustomSealConfig = pdfSealDesigner.getConfig();
pdfSealThemeSelect.value = 'custom';
// Alterna para aba PDF
document.querySelector('[data-tab="tab-pdf"]').click();
alert('Selo customizado selecionado para a assinatura do PDF!');
});
}
// =========================================================================
// ABA 3: LÓGICA DE XML (XAdES-BES & XMLDSig)
// ========================================================================= // =========================================================================
const xmlContentInput = document.getElementById('xmlContentInput'); const xmlContentInput = document.getElementById('xmlContentInput');
const xmlDigestBox = document.getElementById('xmlDigestBox'); const xmlProfileSelect = document.getElementById('xmlProfileSelect');
const btnSignXml = document.getElementById('btnSignXml'); const btnSignXmlFull = document.getElementById('btnSignXmlFull');
const btnSignXmlDigest = document.getElementById('btnSignXmlDigest');
const btnLoadXmlSample = document.getElementById('btnLoadXmlSample'); const btnLoadXmlSample = document.getElementById('btnLoadXmlSample');
const xmlAlert = document.getElementById('xmlAlert'); const xmlAlert = document.getElementById('xmlAlert');
const xmlSigOutput = document.getElementById('xmlSigOutput'); const xmlSigOutput = document.getElementById('xmlSigOutput');
const xmlTagOutput = document.getElementById('xmlTagOutput'); const btnDownloadXml = document.getElementById('btnDownloadXml');
const btnCopyXml = document.getElementById('btnCopyXml');
let currentXmlDigest = null;
const sampleXml = `<nfeProc versao="4.00" xmlns="http://www.portalfiscal.inf.br/nfe"> const sampleXml = `<nfeProc versao="4.00" xmlns="http://www.portalfiscal.inf.br/nfe">
<NFe> <NFe>
@@ -1083,30 +1363,52 @@
</NFe> </NFe>
</nfeProc>`; </nfeProc>`;
async function updateXmlDigest() { xmlContentInput.addEventListener('input', () => updateButtonStates());
const xml = xmlContentInput.value.trim();
if (!xml) {
currentXmlDigest = null;
xmlDigestBox.textContent = 'Digite ou cole o XML para calcular o digest...';
} else {
currentXmlDigest = await signer._calculateSha256(xml);
xmlDigestBox.textContent = `SHA-256 Digest (Base64): ${currentXmlDigest}\nTamanho: ${xml.length} caracteres`;
}
updateButtonStates();
}
xmlContentInput.addEventListener('input', updateXmlDigest);
btnLoadXmlSample.addEventListener('click', () => { btnLoadXmlSample.addEventListener('click', () => {
xmlContentInput.value = sampleXml; xmlContentInput.value = sampleXml;
updateXmlDigest(); updateButtonStates();
}); });
btnSignXml.addEventListener('click', async () => { // Assinatura Completa de XML
btnSignXml.disabled = true; btnSignXmlFull.addEventListener('click', async () => {
btnSignXml.innerHTML = '<span class="spinner"></span> Solicitando no Agente...'; btnSignXmlFull.disabled = true;
btnSignXmlFull.innerHTML = '<span class="spinner"></span> Assinando no Agente...';
xmlAlert.style.display = 'block'; xmlAlert.style.display = 'block';
xmlAlert.className = 'alert alert-info'; xmlAlert.className = 'alert alert-info';
xmlAlert.textContent = 'Confirme a assinatura na janela do CyberCore Signer...'; xmlAlert.textContent = 'Assinando XML no CyberCore Signer...';
try {
const xml = xmlContentInput.value.trim();
const profile = xmlProfileSelect.value;
const result = await signer.signXmlDocument({
certificateId: selectedCertificateId,
xml: xml,
profile: profile
});
xmlAlert.className = 'alert alert-success';
xmlAlert.textContent = `✓ XML assinado com sucesso via perfil ${result.profile || profile}!`;
currentSignedXml = result.signed_xml;
xmlSigOutput.textContent = result.signed_xml;
btnDownloadXml.style.display = 'inline-flex';
btnCopyXml.style.display = 'inline-flex';
} catch (err) {
xmlAlert.className = 'alert alert-error';
xmlAlert.textContent = 'Erro ao assinar XML: ' + err.message;
} finally {
btnSignXmlFull.disabled = false;
btnSignXmlFull.innerHTML = '<span>✍️</span> Assinar XML Completo no Agente';
}
});
// Assinatura via Digest de XML
btnSignXmlDigest.addEventListener('click', async () => {
btnSignXmlDigest.disabled = true;
btnSignXmlDigest.innerHTML = '<span class="spinner"></span> Solicitando...';
xmlAlert.style.display = 'block';
xmlAlert.className = 'alert alert-info';
xmlAlert.textContent = 'Assinando digest do XML no CyberCore Signer...';
try { try {
const result = await signer.signXml(xmlContentInput.value, { const result = await signer.signXml(xmlContentInput.value, {
@@ -1115,33 +1417,40 @@
}); });
xmlAlert.className = 'alert alert-success'; xmlAlert.className = 'alert alert-success';
xmlAlert.textContent = `✓ XML assinado com sucesso via ${result.algorithm}!`; xmlAlert.textContent = `✓ Digest XML assinado com sucesso via ${result.algorithm}!`;
xmlSigOutput.textContent = result.signature; xmlSigOutput.textContent = result.signature;
const xmlSigSnippet = `<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
<SignedInfo>
<SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/>
<Reference URI="#NFe35261012345678000199550010000001001234567890">
<DigestValue>${result.digest}</DigestValue>
</Reference>
</SignedInfo>
<SignatureValue>${result.signature.slice(0, 48)}...</SignatureValue>
<KeyInfo>
<X509Data>
<X509Certificate>${result.certificate.slice(0, 48)}...</X509Certificate>
</X509Data>
</KeyInfo>
</Signature>`;
xmlTagOutput.textContent = xmlSigSnippet;
} catch (err) { } catch (err) {
xmlAlert.className = 'alert alert-error'; xmlAlert.className = 'alert alert-error';
xmlAlert.textContent = 'Erro ao assinar XML: ' + err.message; xmlAlert.textContent = 'Erro ao assinar digest do XML: ' + err.message;
} finally { } finally {
btnSignXml.disabled = false; btnSignXmlDigest.disabled = false;
btnSignXml.innerHTML = '<span>✍️</span> Assinar XML com CyberCore'; btnSignXmlDigest.innerHTML = '<span>🛡️</span> Apenas Digest';
} }
}); });
// Download do XML Assinado
btnDownloadXml.addEventListener('click', () => {
if (!currentSignedXml) return;
const blob = new Blob([currentSignedXml], { type: 'application/xml;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'documento_assinado.xml';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
});
// Copiar XML
btnCopyXml.addEventListener('click', async () => {
if (!currentSignedXml) return;
await navigator.clipboard.writeText(currentSignedXml);
const origText = btnCopyXml.innerHTML;
btnCopyXml.innerHTML = '<span>✓</span> Copiado!';
setTimeout(() => { btnCopyXml.innerHTML = origText; }, 2000);
});
// ========================================================================= // =========================================================================
// ABA 3: LÓGICA DE ARQUIVOS GERAIS // ABA 3: LÓGICA DE ARQUIVOS GERAIS
// ========================================================================= // =========================================================================
+639
View File
@@ -0,0 +1,639 @@
/**
* CyberCore Signer - PdfSealDesigner
* Ferramenta visual interativa para criação, personalização e exportação
* de temas de selos de assinatura digital PAdES (ICP-Brasil).
*/
export class PdfSealDesigner {
/**
* @param {Object} options Configurações iniciais do designer.
* @param {HTMLElement|string} options.container Elemento onde renderizar o designer.
* @param {Object} [options.initialTheme] Configuração inicial de tema.
* @param {Function} [options.onChange] Callback chamado quando o tema for modificado: fn(theme).
*/
constructor(options = {}) {
this.container = typeof options.container === 'string'
? document.querySelector(options.container)
: options.container;
if (!this.container) {
throw new Error('Container DOM obrigatório para instanciar PdfSealDesigner.');
}
this.onChange = options.onChange || null;
// Estado do tema
this.theme = {
name: 'Meu Selo Personalizado',
description: 'Selo visual corporativo customizado via CyberCore Web SDK',
width: 320,
height: 82,
primaryColor: '#0d47a1',
secondaryColor: '#0284c7',
backgroundColor: '#ffffff',
borderColor: '#93c5fd',
textColor: '#0f172a',
iconStyle: 'shield',
customLogoBase64: null,
customHeader: 'CYBERCORE • ICP-BRASIL',
customFooter: 'Validade jurídica assegurada pela MP nº 2.200-2/2001',
customFieldLabel: 'Cargo',
customFieldValue: 'Diretor Responsável',
defaultCpfMode: 'MASKED',
defaultIncludeDateTime: true,
...(options.initialTheme || {})
};
this._initDom();
this._attachEvents();
this._renderPreview();
}
_initDom() {
this.container.innerHTML = `
<div class="cyber-designer-root">
<!-- Cabeçalho -->
<div class="designer-header">
<h3>🎨 Criador de Selo Visual (PAdES / ICP-Brasil)</h3>
<p>Personalize cores, brasão, logotipo próprio e regras LGPD para a estampa no PDF.</p>
</div>
<div class="designer-body">
<!-- Coluna 1: Formulário de Configuração -->
<div class="designer-form">
<div class="form-row">
<label>Nome do Selo:</label>
<input type="text" class="input-theme-name" value="${this.theme.name}" />
</div>
<div class="form-section-title">Paleta de Cores</div>
<div class="colors-grid">
<div class="color-picker-item">
<label>Primária:</label>
<div class="color-input-wrap">
<input type="color" class="color-primary" value="${this.theme.primaryColor}" />
<span class="color-hex-text">${this.theme.primaryColor}</span>
</div>
</div>
<div class="color-picker-item">
<label>Secundária:</label>
<div class="color-input-wrap">
<input type="color" class="color-secondary" value="${this.theme.secondaryColor}" />
<span class="color-hex-text">${this.theme.secondaryColor}</span>
</div>
</div>
<div class="color-picker-item">
<label>Fundo:</label>
<div class="color-input-wrap">
<input type="color" class="color-bg" value="${this.theme.backgroundColor}" />
<span class="color-hex-text">${this.theme.backgroundColor}</span>
</div>
</div>
<div class="color-picker-item">
<label>Borda:</label>
<div class="color-input-wrap">
<input type="color" class="color-border" value="${this.theme.borderColor}" />
<span class="color-hex-text">${this.theme.borderColor}</span>
</div>
</div>
<div class="color-picker-item">
<label>Texto:</label>
<div class="color-input-wrap">
<input type="color" class="color-text" value="${this.theme.textColor}" />
<span class="color-hex-text">${this.theme.textColor}</span>
</div>
</div>
</div>
<div class="form-section-title">Ícone e Emblema</div>
<div class="icon-selection-row">
<label class="radio-icon-item">
<input type="radio" name="iconStyle" value="shield" ${this.theme.iconStyle === 'shield' ? 'checked' : ''} />
<span>🛡️ Escudo ICP</span>
</label>
<label class="radio-icon-item">
<input type="radio" name="iconStyle" value="star" ${this.theme.iconStyle === 'star' ? 'checked' : ''} />
<span>⭐ Gov.br</span>
</label>
<label class="radio-icon-item">
<input type="radio" name="iconStyle" value="crystal" ${this.theme.iconStyle === 'crystal' ? 'checked' : ''} />
<span>💎 Modern</span>
</label>
<label class="radio-icon-item">
<input type="radio" name="iconStyle" value="notary" ${this.theme.iconStyle === 'notary' ? 'checked' : ''} />
<span>📜 Notarial</span>
</label>
</div>
<div class="form-row logo-upload-row">
<label>Logotipo Próprio (PNG/JPG):</label>
<div class="logo-actions">
<label class="btn-designer-upload">
<span>📷</span> Escolher Imagem
<input type="file" accept="image/png,image/jpeg,image/svg+xml" class="designer-logo-file" style="display:none;" />
</label>
<button type="button" class="btn-remove-logo" style="${this.theme.customLogoBase64 ? 'display:inline-block;' : 'display:none;'}">Remover</button>
</div>
</div>
<div class="form-section-title">Identificação & LGPD</div>
<div class="form-row-grid">
<div class="form-row">
<label>Rótulo Extra:</label>
<input type="text" class="input-field-label" placeholder="Ex: Cargo ou Matrícula" value="${this.theme.customFieldLabel || ''}" />
</div>
<div class="form-row">
<label>Valor Extra:</label>
<input type="text" class="input-field-value" placeholder="Ex: Auditor Fiscal" value="${this.theme.customFieldValue || ''}" />
</div>
</div>
<div class="form-row-grid">
<div class="form-row">
<label>Exibição do CPF:</label>
<select class="select-cpf-mode">
<option value="FULL" ${this.theme.defaultCpfMode === 'FULL' ? 'selected' : ''}>Completo (123.456.789-00)</option>
<option value="MASKED" ${this.theme.defaultCpfMode === 'MASKED' ? 'selected' : ''}>Mascarado (***.456.789-**)</option>
<option value="NONE" ${this.theme.defaultCpfMode === 'NONE' ? 'selected' : ''}>Ocultar CPF</option>
</select>
</div>
<div class="form-row checkbox-row">
<label class="checkbox-label">
<input type="checkbox" class="chk-date-time" ${this.theme.defaultIncludeDateTime ? 'checked' : ''} />
<span>Incluir Data e Hora</span>
</label>
</div>
</div>
</div>
<!-- Coluna 2: Live Preview e Exportação -->
<div class="designer-preview-column">
<div class="preview-card">
<h4>👁️ Pré-visualização em Alta Resolução (320x82 pt)</h4>
<div class="seal-live-preview-box">
<!-- SVG Dinâmico -->
</div>
<small class="preview-note">Renderização visual idêntica à estampada no PDFBox do agente desktop.</small>
</div>
<div class="json-export-card">
<div class="json-export-header">
<h4>📋 Objeto de Configuração (JSON)</h4>
<button type="button" class="btn-copy-theme-json">Copiar JSON</button>
</div>
<pre class="theme-json-box"><code>{}</code></pre>
</div>
</div>
</div>
</div>
`;
this._injectStyles();
// Cache de elementos
this.root = this.container.querySelector('.cyber-designer-root');
this.previewBox = this.root.querySelector('.seal-live-preview-box');
this.jsonBox = this.root.querySelector('.theme-json-box code');
this.btnRemoveLogo = this.root.querySelector('.btn-remove-logo');
}
_injectStyles() {
if (document.getElementById('cyber-designer-styles')) return;
const style = document.createElement('style');
style.id = 'cyber-designer-styles';
style.textContent = `
.cyber-designer-root {
background: #0f172a;
border: 1px solid #24304f;
border-radius: 12px;
padding: 20px;
color: #f8fafc;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
}
.designer-header {
margin-bottom: 20px;
border-bottom: 1px solid #1e293b;
padding-bottom: 12px;
}
.designer-header h3 {
font-size: 16px;
font-weight: 700;
color: #38bdf8;
}
.designer-header p {
font-size: 12px;
color: #94a3b8;
margin-top: 4px;
}
.designer-body {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
}
@media (max-width: 860px) {
.designer-body {
grid-template-columns: 1fr;
}
}
.form-section-title {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #38bdf8;
margin: 14px 0 8px 0;
border-top: 1px solid #1e293b;
padding-top: 10px;
}
.form-row {
margin-bottom: 10px;
display: flex;
flex-direction: column;
gap: 4px;
}
.form-row label {
font-size: 11px;
color: #cbd5e1;
}
.form-row input[type="text"], .form-row select {
background: #090d16;
border: 1px solid #24304f;
border-radius: 6px;
padding: 7px 10px;
color: #f8fafc;
font-size: 12px;
}
.form-row input[type="text"]:focus, .form-row select:focus {
border-color: #38bdf8;
outline: none;
}
.form-row-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.colors-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
.color-picker-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.color-picker-item label {
font-size: 10px;
color: #94a3b8;
}
.color-input-wrap {
display: flex;
align-items: center;
gap: 6px;
background: #090d16;
border: 1px solid #24304f;
border-radius: 6px;
padding: 3px 6px;
}
.color-input-wrap input[type="color"] {
border: none;
width: 22px;
height: 22px;
background: transparent;
cursor: pointer;
}
.color-hex-text {
font-size: 10px;
font-family: monospace;
color: #cbd5e1;
}
.icon-selection-row {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.radio-icon-item {
display: flex;
align-items: center;
gap: 6px;
background: #090d16;
border: 1px solid #24304f;
border-radius: 6px;
padding: 6px 10px;
font-size: 11px;
cursor: pointer;
}
.logo-upload-row .logo-actions {
display: flex;
align-items: center;
gap: 8px;
}
.btn-designer-upload {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
background: #1e293b;
border: 1px solid #38bdf8;
border-radius: 6px;
color: #38bdf8;
font-size: 11px;
cursor: pointer;
}
.btn-remove-logo {
background: transparent;
border: 1px solid #f87171;
color: #f87171;
padding: 4px 8px;
border-radius: 6px;
font-size: 11px;
cursor: pointer;
}
.checkbox-row {
justify-content: flex-end;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
cursor: pointer;
margin-top: 18px;
}
.designer-preview-column {
display: flex;
flex-direction: column;
gap: 16px;
}
.preview-card, .json-export-card {
background: #131c31;
border: 1px solid #24304f;
border-radius: 10px;
padding: 16px;
}
.preview-card h4, .json-export-card h4 {
font-size: 12px;
font-weight: 700;
color: #cbd5e1;
margin-bottom: 12px;
}
.seal-live-preview-box {
background: #ffffff;
border-radius: 6px;
padding: 16px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 15px rgba(0,0,0,0.3);
}
.seal-live-preview-box svg {
max-width: 100%;
height: auto;
display: block;
}
.preview-note {
display: block;
font-size: 10px;
color: #64748b;
margin-top: 8px;
text-align: center;
}
.json-export-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.btn-copy-theme-json {
background: #1e293b;
border: 1px solid #24304f;
color: #38bdf8;
padding: 3px 8px;
border-radius: 4px;
font-size: 11px;
cursor: pointer;
}
.btn-copy-theme-json:hover {
background: #38bdf8;
color: #0f172a;
}
.theme-json-box {
background: #090d16;
border: 1px solid #1e293b;
border-radius: 6px;
padding: 10px;
font-family: 'JetBrains Mono', monospace;
font-size: 10.5px;
color: #38bdf8;
max-height: 180px;
overflow: auto;
margin: 0;
}
`;
document.head.appendChild(style);
}
_attachEvents() {
const bindInput = (selector, key, formatFn = v => v) => {
this.root.querySelector(selector).addEventListener('input', e => {
this.theme[key] = formatFn(e.target.value);
this._onStateChange();
});
};
bindInput('.input-theme-name', 'name');
bindInput('.input-field-label', 'customFieldLabel');
bindInput('.input-field-value', 'customFieldValue');
// Cores
const bindColor = (selector, key, labelSelector) => {
const el = this.root.querySelector(selector);
const label = this.root.querySelector(labelSelector);
el.addEventListener('input', e => {
this.theme[key] = e.target.value;
label.textContent = e.target.value;
this._onStateChange();
});
};
bindColor('.color-primary', 'primaryColor', '.color-primary + .color-hex-text');
bindColor('.color-secondary', 'secondaryColor', '.color-secondary + .color-hex-text');
bindColor('.color-bg', 'backgroundColor', '.color-bg + .color-hex-text');
bindColor('.color-border', 'borderColor', '.color-border + .color-hex-text');
bindColor('.color-text', 'textColor', '.color-text + .color-hex-text');
// Ícones
this.root.querySelectorAll('input[name="iconStyle"]').forEach(radio => {
radio.addEventListener('change', e => {
if (e.target.checked) {
this.theme.iconStyle = e.target.value;
this._onStateChange();
}
});
});
// Upload de logo
const logoInput = this.root.querySelector('.designer-logo-file');
logoInput.addEventListener('change', async e => {
if (e.target.files.length) {
const file = e.target.files[0];
const reader = new FileReader();
reader.onload = ev => {
this.theme.customLogoBase64 = ev.target.result;
this.btnRemoveLogo.style.display = 'inline-block';
this._onStateChange();
};
reader.readAsDataURL(file);
}
});
this.btnRemoveLogo.addEventListener('click', () => {
this.theme.customLogoBase64 = null;
this.btnRemoveLogo.style.display = 'none';
logoInput.value = '';
this._onStateChange();
});
// LGPD CPF
this.root.querySelector('.select-cpf-mode').addEventListener('change', e => {
this.theme.defaultCpfMode = e.target.value;
this._onStateChange();
});
// Data e hora
this.root.querySelector('.chk-date-time').addEventListener('change', e => {
this.theme.defaultIncludeDateTime = e.target.checked;
this._onStateChange();
});
// Copiar JSON
this.root.querySelector('.btn-copy-theme-json').addEventListener('click', () => {
const config = this.getThemeConfig();
navigator.clipboard.writeText(JSON.stringify(config, null, 2));
const btn = this.root.querySelector('.btn-copy-theme-json');
btn.textContent = '✓ Copiado!';
setTimeout(() => (btn.textContent = 'Copiar JSON'), 1500);
});
}
_onStateChange() {
this._renderPreview();
if (this.onChange) {
this.onChange(this.getThemeConfig());
}
}
_renderPreview() {
const t = this.theme;
// CPF conforme a escolha
let docText = 'CPF: ***.456.789-**';
if (t.defaultCpfMode === 'FULL') docText = 'CPF: 123.456.789-00';
else if (t.defaultCpfMode === 'NONE') docText = '';
const dateText = t.defaultIncludeDateTime ? '11/10/2026 18:30:00' : '';
let extraText = '';
if (t.customFieldLabel || t.customFieldValue) {
const label = t.customFieldLabel ? `${t.customFieldLabel}: ` : '';
const val = t.customFieldValue || '';
extraText = `${label}${val}`.trim();
}
let iconGraphic = '';
if (t.customLogoBase64) {
iconGraphic = `<image href="${t.customLogoBase64}" x="12" y="14" width="34" height="34" preserveAspectRatio="xMidYMid meet" />`;
} else {
switch (t.iconStyle) {
case 'star':
iconGraphic = `
<circle cx="28" cy="30" r="15" fill="${t.primaryColor}" opacity="0.12" />
<circle cx="28" cy="30" r="15" fill="none" stroke="${t.primaryColor}" stroke-width="1.5" />
<path d="M28 18 L31 26 L39 26 L33 31 L35 39 L28 34 L21 39 L23 31 L17 26 L25 26 Z" fill="${t.secondaryColor}" />
`;
break;
case 'crystal':
iconGraphic = `
<polygon points="28,14 42,22 42,38 28,46 14,38 14,22" fill="${t.primaryColor}" opacity="0.15" />
<polygon points="28,14 42,22 42,38 28,46 14,38 14,22" fill="none" stroke="${t.primaryColor}" stroke-width="1.5" />
<line x1="28" y1="14" x2="28" y2="46" stroke="${t.secondaryColor}" stroke-width="1.5" />
`;
break;
case 'notary':
iconGraphic = `
<circle cx="28" cy="30" r="16" fill="none" stroke="${t.primaryColor}" stroke-width="2" stroke-dasharray="3,2" />
<circle cx="28" cy="30" r="12" fill="${t.primaryColor}" opacity="0.1" />
<path d="M22 30 L26 34 L34 26" fill="none" stroke="${t.secondaryColor}" stroke-width="2" stroke-linecap="round" />
`;
break;
case 'shield':
default:
iconGraphic = `
<path d="M28 12 L40 17 V28 C40 38 33 46 28 49 C23 46 16 38 16 28 V17 Z" fill="${t.primaryColor}" opacity="0.12" />
<path d="M28 12 L40 17 V28 C40 38 33 46 28 49 C23 46 16 38 16 28 V17 Z" fill="none" stroke="${t.primaryColor}" stroke-width="1.8" />
<rect x="23" y="27" width="10" height="8" rx="2" fill="${t.secondaryColor}" />
<path d="M25 27 V23.5 C25 21.8 26.3 20.5 28 20.5 C29.7 20.5 31 21.8 31 23.5 V27" fill="none" stroke="${t.secondaryColor}" stroke-width="1.5" />
`;
break;
}
}
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 82" width="320" height="82" style="border-radius:6px; background:${t.backgroundColor};">
<rect x="1" y="1" width="318" height="80" rx="6" fill="${t.backgroundColor}" stroke="${t.borderColor}" stroke-width="1.5"/>
<rect x="1" y="1" width="6" height="80" rx="3" fill="${t.primaryColor}"/>
<!-- Emblema / Brasão -->
<g id="seal-icon-group">
${iconGraphic}
</g>
<!-- Tipografia do Selo -->
<text x="54" y="19" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="8.5" font-weight="bold" fill="${t.primaryColor}" letter-spacing="0.3">${t.customHeader || 'DOCUMENTO ASSINADO DIGITALMENTE'}</text>
<text x="54" y="34" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="12" font-weight="bold" fill="${t.textColor}">MARIA DA SILVA</text>
<text x="54" y="47" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="8" fill="#475569">${[docText, extraText].filter(Boolean).join(' • ')}</text>
<text x="54" y="58" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="8" fill="#475569">${[dateText, 'AC SERPRO v5'].filter(Boolean).join(' • ')}</text>
<text x="54" y="70" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="7.5" fill="#64748b">${t.customFooter || 'ICP-Brasil • MP nº 2.200-2/2001'}</text>
</svg>
`;
this.previewBox.innerHTML = svg;
this.jsonBox.textContent = JSON.stringify(this.getThemeConfig(), null, 2);
}
/**
* Retorna a configuração do tema pronto para uso no payload seal_custom de assinatura.
*/
getThemeConfig() {
return {
name: this.theme.name,
description: this.theme.description,
width: this.theme.width,
height: this.theme.height,
primaryColor: this.theme.primaryColor,
secondaryColor: this.theme.secondaryColor,
backgroundColor: this.theme.backgroundColor,
borderColor: this.theme.borderColor,
textColor: this.theme.textColor,
iconStyle: this.theme.iconStyle,
customLogoBase64: this.theme.customLogoBase64,
customHeader: this.theme.customHeader,
customFooter: this.theme.customFooter,
customFieldLabel: this.theme.customFieldLabel,
customFieldValue: this.theme.customFieldValue,
defaultCpfMode: this.theme.defaultCpfMode,
defaultIncludeDateTime: this.theme.defaultIncludeDateTime
};
}
/**
* Carrega um tema para edição.
*/
loadTheme(theme) {
this.theme = { ...this.theme, ...theme };
this._initDom();
this._attachEvents();
this._renderPreview();
}
}
export default PdfSealDesigner;
+925
View File
@@ -0,0 +1,925 @@
/**
* CyberCore Signer - PdfStampPicker
* Componente visual para pré-visualização de PDF, posicionamento interativo de selos
* e geração do payload de assinatura em tempo real.
*/
export class PdfStampPicker {
/**
* @param {Object} options Configurações do componente.
* @param {HTMLElement|string} options.container Elemento DOM ou seletor onde renderizar o picker.
* @param {File|Blob|ArrayBuffer|string} [options.pdf] Arquivo ou Base64 inicial do PDF.
* @param {Object} [options.sealTheme] Configurações visuais do selo a estampar.
* @param {number} [options.initialScale=1.0] Escala inicial do selo (ex: 1.0 = 100%).
* @param {Function} [options.onPositionChange] Callback chamado a cada alteração de página ou coordenadas: fn(info).
* @param {Function} [options.onSignRequest] Callback acionado ao clicar em "Assinar": fn(payload).
*/
constructor(options = {}) {
this.container = typeof options.container === 'string'
? document.querySelector(options.container)
: options.container;
if (!this.container) {
throw new Error('Container DOM obrigatório para instanciar PdfStampPicker.');
}
this.onPositionChange = options.onPositionChange || null;
this.onSignRequest = options.onSignRequest || null;
this.sealTheme = options.sealTheme || null;
// Estado interno
this.pdfDocument = null;
this.pdfBytes = null;
this.pdfBase64 = null;
this.documentName = 'documento.pdf';
this.totalPages = 1;
this.currentPageNumber = 1;
this.zoom = 1.0;
// Dimensões do PDF na página ativa (em pontos tipográficos 72 DPI)
this.pagePdfWidth = 595.28;
this.pagePdfHeight = 841.89;
// Estado do selo
this.sealScale = options.initialScale || 1.0;
this.baseSealWidth = 230; // pixels base no visualizador
this.baseSealHeight = 60;
this.sealPosX = null; // pixel relativo ao container da folha
this.sealPosY = null;
this.isDragging = false;
this.dragStartX = 0;
this.dragStartY = 0;
// Coordenadas finais em pontos do PDF
this.calculatedPdfX = 0;
this.calculatedPdfY = 0;
this.selectedPositionEnum = 'BOTTOM_RIGHT';
this._initDom();
this._attachEvents();
if (options.pdf) {
this.loadPdf(options.pdf);
}
}
_initDom() {
this.container.innerHTML = `
<div class="cyber-picker-root">
<!-- Barra Superior de Ferramentas -->
<div class="picker-toolbar">
<div class="toolbar-group">
<label class="btn-picker-upload">
<span>📄</span> Carregar PDF
<input type="file" accept=".pdf,application/pdf" class="picker-file-input" style="display:none;" />
</label>
<span class="picker-doc-name" title="Nenhum arquivo carregado">Nenhum PDF selecionado</span>
</div>
<div class="toolbar-group nav-group">
<button type="button" class="btn-picker-tool btn-first-page" title="Primeira Página">⏮</button>
<button type="button" class="btn-picker-tool btn-prev-page" title="Página Anterior">◀</button>
<span class="page-counter">Pág. <b class="current-page-num">1</b> de <span class="total-pages-num">1</span></span>
<button type="button" class="btn-picker-tool btn-next-page" title="Próxima Página">▶</button>
<button type="button" class="btn-picker-tool btn-last-page" title="Última Página">⏭</button>
</div>
<div class="toolbar-group zoom-group">
<button type="button" class="btn-picker-tool btn-zoom-out" title="Diminuir Zoom">-</button>
<span class="zoom-level-text">100%</span>
<button type="button" class="btn-picker-tool btn-zoom-in" title="Aumentar Zoom">+</button>
</div>
</div>
<!-- Área Central Dividida: Folha Interativa + Painel de Payload -->
<div class="picker-body">
<!-- Visor da Folha do PDF -->
<div class="picker-viewport">
<div class="picker-sheet-wrapper">
<div class="sheet-container">
<canvas class="pdf-canvas"></canvas>
<!-- Selo Visual Interativo (Drag & Drop) -->
<div class="stamp-overlay" style="display:none;">
<div class="stamp-content">
<div class="stamp-icon-box">
<svg viewBox="0 0 24 24" class="stamp-svg-icon" fill="currentColor">
<path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm-1 6h2v2h-2V7zm0 4h2v6h-2v-6z"/>
</svg>
</div>
<div class="stamp-info-box">
<div class="stamp-badge-text">ASSINADO DIGITALMENTE • ICP-BRASIL</div>
<div class="stamp-signer-name">NOME DO TITULAR DO CERTIFICADO</div>
<div class="stamp-signer-meta">CPF: ***.456.789-** • VALIDADE JURÍDICA</div>
</div>
</div>
<div class="stamp-resize-handle" title="Arraste para redimensionar o selo"></div>
</div>
</div>
</div>
<div class="picker-empty-state">
<div class="empty-icon">📄</div>
<p>Nenhum PDF carregado</p>
<small>Arraste um PDF aqui ou clique em "Carregar PDF"</small>
</div>
</div>
<!-- Painel Lateral: Controles & Inspetor de Payload em Tempo Real -->
<div class="picker-sidebar">
<div class="sidebar-section">
<h3>📍 Posição do Selo</h3>
<div class="preset-buttons">
<button type="button" class="btn-preset active" data-pos="BOTTOM_RIGHT">Inferior Dir.</button>
<button type="button" class="btn-preset" data-pos="BOTTOM_LEFT">Inferior Esq.</button>
<button type="button" class="btn-preset" data-pos="TOP_RIGHT">Superior Dir.</button>
<button type="button" class="btn-preset" data-pos="TOP_LEFT">Superior Esq.</button>
</div>
<small class="hint-text">Dica: Você também pode clicar ou arrastar o selo livremente sobre a folha.</small>
</div>
<div class="sidebar-section">
<h3>📏 Tamanho do Selo</h3>
<div class="scale-slider-row">
<input type="range" class="seal-scale-range" min="0.4" max="1.8" step="0.05" value="1.0" />
<span class="scale-val-label">100%</span>
</div>
</div>
<div class="sidebar-section">
<div class="coords-grid">
<div class="coord-item">
<span class="coord-label">Página:</span>
<span class="coord-val val-page">1</span>
</div>
<div class="coord-item">
<span class="coord-label">Coordenada X:</span>
<span class="coord-val val-x">0.0 pt</span>
</div>
<div class="coord-item">
<span class="coord-label">Coordenada Y:</span>
<span class="coord-val val-y">0.0 pt</span>
</div>
<div class="coord-item">
<span class="coord-label">Escala:</span>
<span class="coord-val val-scale">1.0x</span>
</div>
</div>
</div>
<!-- Bloco de Código com o Payload Formatado -->
<div class="sidebar-section flex-grow">
<div class="payload-header">
<h3>⚡ Payload da Requisição</h3>
<button type="button" class="btn-copy-payload" title="Copiar JSON">Copiar</button>
</div>
<pre class="payload-code-box"><code>{}</code></pre>
</div>
<div class="sidebar-footer">
<button type="button" class="btn-trigger-sign">
<span>✍️</span> Assinar com CyberCore
</button>
</div>
</div>
</div>
</div>
`;
this._injectStyles();
// Cache de elementos do DOM
this.root = this.container.querySelector('.cyber-picker-root');
this.fileInput = this.root.querySelector('.picker-file-input');
this.docNameLabel = this.root.querySelector('.picker-doc-name');
this.viewport = this.root.querySelector('.picker-viewport');
this.sheetWrapper = this.root.querySelector('.picker-sheet-wrapper');
this.sheetContainer = this.root.querySelector('.sheet-container');
this.canvas = this.root.querySelector('.pdf-canvas');
this.stampOverlay = this.root.querySelector('.stamp-overlay');
this.emptyState = this.root.querySelector('.picker-empty-state');
this.currentPageLabel = this.root.querySelector('.current-page-num');
this.totalPagesLabel = this.root.querySelector('.total-pages-num');
this.zoomLabel = this.root.querySelector('.zoom-level-text');
this.scaleRange = this.root.querySelector('.seal-scale-range');
this.scaleValLabel = this.root.querySelector('.scale-val-label');
this.valPage = this.root.querySelector('.val-page');
this.valX = this.root.querySelector('.val-x');
this.valY = this.root.querySelector('.val-y');
this.valScale = this.root.querySelector('.val-scale');
this.payloadCode = this.root.querySelector('.payload-code-box code');
this.btnCopyPayload = this.root.querySelector('.btn-copy-payload');
this.btnTriggerSign = this.root.querySelector('.btn-trigger-sign');
}
_injectStyles() {
if (document.getElementById('cyber-picker-styles')) return;
const style = document.createElement('style');
style.id = 'cyber-picker-styles';
style.textContent = `
.cyber-picker-root {
display: flex;
flex-direction: column;
background: #090d16;
border: 1px solid #24304f;
border-radius: 12px;
overflow: hidden;
color: #f8fafc;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.4);
}
.picker-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
background: #0f172a;
border-bottom: 1px solid #24304f;
flex-wrap: wrap;
gap: 12px;
}
.toolbar-group {
display: flex;
align-items: center;
gap: 8px;
}
.btn-picker-upload {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
background: #1e293b;
border: 1px solid #38bdf8;
border-radius: 6px;
color: #38bdf8;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn-picker-upload:hover {
background: rgba(56, 189, 248, 0.15);
}
.picker-doc-name {
font-size: 13px;
color: #94a3b8;
max-width: 180px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.btn-picker-tool {
background: #18223c;
border: 1px solid #24304f;
color: #f8fafc;
padding: 5px 10px;
border-radius: 5px;
cursor: pointer;
font-size: 12px;
transition: all 0.2s;
}
.btn-picker-tool:hover:not(:disabled) {
background: #24304f;
color: #38bdf8;
}
.btn-picker-tool:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.page-counter, .zoom-level-text {
font-size: 12px;
color: #94a3b8;
padding: 0 4px;
}
.picker-body {
display: flex;
min-height: 520px;
height: 600px;
}
.picker-viewport {
flex: 1;
background: #060911;
overflow: auto;
position: relative;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.picker-sheet-wrapper {
display: none;
box-shadow: 0 12px 35px rgba(0,0,0,0.65);
border: 1px solid rgba(255,255,255,0.1);
background: #ffffff;
}
.sheet-container {
position: relative;
line-height: 0;
cursor: crosshair;
user-select: none;
}
.pdf-canvas {
display: block;
max-width: none;
}
.stamp-overlay {
position: absolute;
width: 230px;
height: 60px;
border: 2px dashed #0284c7;
background: rgba(13, 71, 161, 0.08);
border-radius: 6px;
box-shadow: 0 4px 15px rgba(0,0,0,0.25);
cursor: grab;
box-sizing: border-box;
overflow: hidden;
z-index: 20;
transition: box-shadow 0.2s, border-color 0.2s;
}
.stamp-overlay:active {
cursor: grabbing;
border-color: #38bdf8;
box-shadow: 0 6px 20px rgba(56, 189, 248, 0.4);
}
.stamp-content {
width: 100%;
height: 100%;
display: flex;
align-items: center;
padding: 4px 8px;
box-sizing: border-box;
gap: 8px;
pointer-events: none;
}
.stamp-icon-box {
width: 34px;
height: 34px;
color: #0d47a1;
flex-shrink: 0;
}
.stamp-svg-icon {
width: 100%;
height: 100%;
}
.stamp-info-box {
display: flex;
flex-direction: column;
justify-content: center;
line-height: 1.15;
}
.stamp-badge-text {
font-size: 7.5px;
font-weight: 800;
color: #0d47a1;
letter-spacing: 0.3px;
}
.stamp-signer-name {
font-size: 10px;
font-weight: 800;
color: #0f172a;
margin: 1px 0;
}
.stamp-signer-meta {
font-size: 7.5px;
color: #475569;
}
.picker-empty-state {
text-align: center;
color: #64748b;
}
.picker-empty-state .empty-icon {
font-size: 48px;
margin-bottom: 12px;
}
.picker-sidebar {
width: 340px;
background: #0f172a;
border-left: 1px solid #24304f;
display: flex;
flex-direction: column;
padding: 16px;
box-sizing: border-box;
gap: 16px;
overflow-y: auto;
}
.sidebar-section h3 {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.6px;
color: #38bdf8;
margin-bottom: 8px;
}
.preset-buttons {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.btn-preset {
background: #18223c;
border: 1px solid #24304f;
color: #cbd5e1;
font-size: 11px;
padding: 6px 8px;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
}
.btn-preset:hover {
background: #24304f;
color: #f8fafc;
}
.btn-preset.active {
background: rgba(56, 189, 248, 0.2);
border-color: #38bdf8;
color: #38bdf8;
font-weight: 600;
}
.hint-text {
display: block;
font-size: 11px;
color: #64748b;
margin-top: 6px;
}
.scale-slider-row {
display: flex;
align-items: center;
gap: 10px;
}
.seal-scale-range {
flex: 1;
accent-color: #38bdf8;
}
.scale-val-label {
font-size: 12px;
color: #38bdf8;
font-weight: 600;
min-width: 40px;
}
.coords-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
background: #131c31;
padding: 10px;
border-radius: 8px;
border: 1px solid #24304f;
}
.coord-item {
display: flex;
flex-direction: column;
gap: 2px;
}
.coord-label {
font-size: 10px;
color: #94a3b8;
text-transform: uppercase;
}
.coord-val {
font-size: 13px;
font-weight: 700;
color: #f8fafc;
font-family: monospace;
}
.payload-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.btn-copy-payload {
background: #1e293b;
border: 1px solid #24304f;
color: #cbd5e1;
font-size: 11px;
padding: 3px 8px;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
}
.btn-copy-payload:hover {
background: #38bdf8;
color: #0f172a;
}
.payload-code-box {
background: #090d16;
border: 1px solid #1e293b;
border-radius: 6px;
padding: 10px;
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: #34d399;
overflow: auto;
max-height: 160px;
margin: 0;
}
.flex-grow {
flex: 1;
display: flex;
flex-direction: column;
}
.btn-trigger-sign {
width: 100%;
background: linear-gradient(135deg, #0284c7, #2563eb);
border: none;
color: white;
padding: 10px;
border-radius: 8px;
font-size: 13px;
font-weight: 700;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: all 0.2s;
box-shadow: 0 4px 12px rgba(2, 132, 199, 0.35);
}
.btn-trigger-sign:hover {
background: linear-gradient(135deg, #0ea5e9, #3b82f6);
box-shadow: 0 6px 18px rgba(2, 132, 199, 0.5);
}
`;
document.head.appendChild(style);
}
_attachEvents() {
// Carregamento de arquivo
this.fileInput.addEventListener('change', e => {
if (e.target.files.length) {
this.loadPdf(e.target.files[0]);
}
});
// Drag and drop do arquivo PDF no viewport
this.viewport.addEventListener('dragover', e => {
e.preventDefault();
this.viewport.style.background = '#0d1527';
});
this.viewport.addEventListener('dragleave', () => {
this.viewport.style.background = '#060911';
});
this.viewport.addEventListener('drop', e => {
e.preventDefault();
this.viewport.style.background = '#060911';
if (e.dataTransfer.files.length) {
this.loadPdf(e.dataTransfer.files[0]);
}
});
// Navegação de páginas
this.root.querySelector('.btn-first-page').addEventListener('click', () => this.goToPage(1));
this.root.querySelector('.btn-prev-page').addEventListener('click', () => this.goToPage(this.currentPageNumber - 1));
this.root.querySelector('.btn-next-page').addEventListener('click', () => this.goToPage(this.currentPageNumber + 1));
this.root.querySelector('.btn-last-page').addEventListener('click', () => this.goToPage(this.totalPages));
// Zoom
this.root.querySelector('.btn-zoom-in').addEventListener('click', () => this.setZoom(this.zoom + 0.15));
this.root.querySelector('.btn-zoom-out').addEventListener('click', () => this.setZoom(this.zoom - 0.15));
// Escala do selo
this.scaleRange.addEventListener('input', e => {
this.setSealScale(parseFloat(e.target.value));
});
// Presets de posição
this.root.querySelectorAll('.btn-preset').forEach(btn => {
btn.addEventListener('click', () => {
this.root.querySelectorAll('.btn-preset').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
this.applyPositionPreset(btn.dataset.pos);
});
});
// Clique direto na folha para posicionar o selo
this.sheetContainer.addEventListener('click', e => {
if (this.isDragging) return;
if (e.target.closest('.stamp-overlay')) return;
const rect = this.sheetContainer.getBoundingClientRect();
const clickX = e.clientX - rect.left;
const clickY = e.clientY - rect.top;
const stampW = this.baseSealWidth * this.sealScale;
const stampH = this.baseSealHeight * this.sealScale;
this.moveStampTo(clickX - stampW / 2, clickY - stampH / 2);
});
// Arraste do selo (mouse)
this.stampOverlay.addEventListener('mousedown', e => {
e.preventDefault();
this.isDragging = true;
const rect = this.stampOverlay.getBoundingClientRect();
this.dragStartX = e.clientX - rect.left;
this.dragStartY = e.clientY - rect.top;
const onMouseMove = moveEvent => {
if (!this.isDragging) return;
const containerRect = this.sheetContainer.getBoundingClientRect();
const newX = moveEvent.clientX - containerRect.left - this.dragStartX;
const newY = moveEvent.clientY - containerRect.top - this.dragStartY;
this.moveStampTo(newX, newY);
};
const onMouseUp = () => {
this.isDragging = false;
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
};
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
});
// Copiar Payload
this.btnCopyPayload.addEventListener('click', () => {
const payload = this.getPayload();
navigator.clipboard.writeText(JSON.stringify(payload, null, 2));
const oldText = this.btnCopyPayload.textContent;
this.btnCopyPayload.textContent = '✓ Copiado!';
this.btnCopyPayload.style.color = '#34d399';
setTimeout(() => {
this.btnCopyPayload.textContent = oldText;
this.btnCopyPayload.style.color = '';
}, 1500);
});
// Assinar
this.btnTriggerSign.addEventListener('click', () => {
if (this.onSignRequest) {
this.onSignRequest(this.getPayload());
}
});
}
/**
* Carrega um documento PDF para visualização e cálculo interativo.
* @param {File|Blob|ArrayBuffer|string} source
*/
async loadPdf(source) {
try {
this.docNameLabel.textContent = 'Carregando documento...';
let arrayBuffer;
if (typeof source === 'string') {
// Base64
const clean = source.includes(',') ? source.split(',')[1] : source;
const binary = atob(clean);
const len = binary.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) bytes[i] = binary.charCodeAt(i);
arrayBuffer = bytes.buffer;
this.pdfBase64 = clean;
this.documentName = 'documento.pdf';
} else if (source instanceof ArrayBuffer) {
arrayBuffer = source;
this.pdfBase64 = this._bufferToBase64(source);
} else if (source instanceof Blob || (typeof File !== 'undefined' && source instanceof File)) {
this.documentName = source.name || 'documento.pdf';
arrayBuffer = await source.arrayBuffer();
this.pdfBase64 = this._bufferToBase64(arrayBuffer);
}
this.pdfBytes = new Uint8Array(arrayBuffer);
this.docNameLabel.textContent = this.documentName;
this.docNameLabel.title = this.documentName;
// Garante suporte ao PDF.js
await this._ensurePdfJsLoaded();
const loadingTask = window.pdfjsLib.getDocument({ data: this.pdfBytes });
this.pdfDocument = await loadingTask.promise;
this.totalPages = this.pdfDocument.numPages;
this.totalPagesLabel.textContent = this.totalPages;
this.emptyState.style.display = 'none';
this.sheetWrapper.style.display = 'block';
// Posiciona na última página por padrão (recomendação ICP-Brasil PAdES)
await this.goToPage(this.totalPages);
// Posiciona o selo no canto inferior direito inicial
this.applyPositionPreset('BOTTOM_RIGHT');
} catch (err) {
console.error('Erro ao renderizar PDF no picker:', err);
this.docNameLabel.textContent = 'Erro ao carregar PDF';
alert('Não foi possível ler o arquivo PDF: ' + err.message);
}
}
async _ensurePdfJsLoaded() {
if (window.pdfjsLib) return;
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js';
script.onload = () => {
window.pdfjsLib.GlobalWorkerOptions.workerSrc =
'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
resolve();
};
script.onerror = () => reject(new Error('Falha ao carregar a biblioteca de renderização de PDF.'));
document.head.appendChild(script);
});
}
async goToPage(pageNum) {
if (!this.pdfDocument) return;
const pageTarget = Math.max(1, Math.min(pageNum, this.totalPages));
this.currentPageNumber = pageTarget;
this.currentPageLabel.textContent = pageTarget;
const page = await this.pdfDocument.getPage(pageTarget);
const unscaledViewport = page.getViewport({ scale: 1.0 });
this.pagePdfWidth = unscaledViewport.width;
this.pagePdfHeight = unscaledViewport.height;
// Renderiza a folha no Canvas com o zoom atual
const viewport = page.getViewport({ scale: this.zoom });
this.canvas.width = viewport.width;
this.canvas.height = viewport.height;
this.sheetContainer.style.width = `${viewport.width}px`;
this.sheetContainer.style.height = `${viewport.height}px`;
const ctx = this.canvas.getContext('2d');
await page.render({ canvasContext: ctx, viewport: viewport }).promise;
this.stampOverlay.style.display = 'block';
// Recalcula limites se já houver posição definida
if (this.sealPosX !== null && this.sealPosY !== null) {
this.moveStampTo(this.sealPosX, this.sealPosY);
} else {
this.applyPositionPreset('BOTTOM_RIGHT');
}
}
setZoom(newZoom) {
const clamped = Math.max(0.5, Math.min(newZoom, 2.0));
this.zoom = Math.round(clamped * 100) / 100;
this.zoomLabel.textContent = `${Math.round(this.zoom * 100)}%`;
if (this.pdfDocument) {
this.goToPage(this.currentPageNumber);
}
}
setSealScale(scale) {
this.sealScale = scale;
this.scaleValLabel.textContent = `${Math.round(scale * 100)}%`;
this.valScale.textContent = `${scale.toFixed(2)}x`;
const stampW = this.baseSealWidth * this.sealScale;
const stampH = this.baseSealHeight * this.sealScale;
this.stampOverlay.style.width = `${stampW}px`;
this.stampOverlay.style.height = `${stampH}px`;
if (this.sealPosX !== null && this.sealPosY !== null) {
this.moveStampTo(this.sealPosX, this.sealPosY);
}
}
applyPositionPreset(preset) {
this.selectedPositionEnum = preset;
const sheetW = this.canvas.width;
const sheetH = this.canvas.height;
const stampW = this.baseSealWidth * this.sealScale;
const stampH = this.baseSealHeight * this.sealScale;
const margin = 20 * this.zoom;
let targetX = 0;
let targetY = 0;
switch (preset) {
case 'BOTTOM_LEFT':
targetX = margin;
targetY = sheetH - stampH - margin;
break;
case 'TOP_RIGHT':
targetX = sheetW - stampW - margin;
targetY = margin;
break;
case 'TOP_LEFT':
targetX = margin;
targetY = margin;
break;
case 'BOTTOM_RIGHT':
default:
targetX = sheetW - stampW - margin;
targetY = sheetH - stampH - margin;
break;
}
this.moveStampTo(targetX, targetY, preset);
}
moveStampTo(x, y, presetName = 'CUSTOM') {
const sheetW = this.canvas.width;
const sheetH = this.canvas.height;
const stampW = this.baseSealWidth * this.sealScale;
const stampH = this.baseSealHeight * this.sealScale;
// Clamping para não ultrapassar as bordas da folha
const clampedX = Math.max(0, Math.min(x, sheetW - stampW));
const clampedY = Math.max(0, Math.min(y, sheetH - stampH));
this.sealPosX = clampedX;
this.sealPosY = clampedY;
this.stampOverlay.style.left = `${clampedX}px`;
this.stampOverlay.style.top = `${clampedY}px`;
if (presetName !== 'CUSTOM') {
this.selectedPositionEnum = presetName;
} else {
this.selectedPositionEnum = 'CUSTOM';
this.root.querySelectorAll('.btn-preset').forEach(b => b.classList.remove('active'));
}
// =========================================================================
// Conversão Matemática para Pontos do PDF (Origem no Rodapé da Página)
// =========================================================================
const scaleRatioX = this.pagePdfWidth / sheetW;
const scaleRatioY = this.pagePdfHeight / sheetH;
this.calculatedPdfX = Math.round((clampedX * scaleRatioX) * 10) / 10;
// No PDF ISO 32000, o eixo Y = 0 é o canto inferior esquerdo:
this.calculatedPdfY = Math.round(((sheetH - clampedY - stampH) * scaleRatioY) * 10) / 10;
this._updateUiFeedback();
}
_updateUiFeedback() {
this.valPage.textContent = this.currentPageNumber;
this.valX.textContent = `${this.calculatedPdfX.toFixed(1)} pt`;
this.valY.textContent = `${this.calculatedPdfY.toFixed(1)} pt`;
this.valScale.textContent = `${this.sealScale.toFixed(2)}x`;
const payload = this.getPayload();
this.payloadCode.textContent = JSON.stringify(payload, null, 2);
if (this.onPositionChange) {
this.onPositionChange({
page: this.currentPageNumber,
x: this.calculatedPdfX,
y: this.calculatedPdfY,
scale: this.sealScale,
position: this.selectedPositionEnum,
pagePdfWidth: this.pagePdfWidth,
pagePdfHeight: this.pagePdfHeight,
payload: payload
});
}
}
/**
* Retorna o payload estruturado pronto para a requisição POST /ccsp/v1/sign-pdf.
*/
getPayload() {
return {
version: '1',
operation: 'sign_pdf',
document_name: this.documentName,
page: this.currentPageNumber,
position: this.selectedPositionEnum,
x: this.calculatedPdfX,
y: this.calculatedPdfY,
scale: Number(this.sealScale.toFixed(2)),
visible_seal: true,
seal_theme_id: this.sealTheme?.id || 'icp-brasil',
seal_custom: this.sealTheme || null,
reason: 'Assinatura Digital ICP-Brasil',
location: 'Brasil',
pdf: this.pdfBase64 ? `${this.pdfBase64.substring(0, 32)}...[${this.pdfBytes?.length || 0} bytes]` : null
};
}
/**
* Retorna os dados completos com o Base64 real para a execução de assinatura.
*/
getFullPayload(certificateId) {
return {
version: '1',
operation: 'sign_pdf',
certificate_hint: certificateId,
document_name: this.documentName,
page: this.currentPageNumber,
position: this.selectedPositionEnum,
x: this.calculatedPdfX,
y: this.calculatedPdfY,
scale: Number(this.sealScale.toFixed(2)),
visible_seal: true,
seal_theme_id: this.sealTheme?.id || 'icp-brasil',
seal_custom: this.sealTheme || null,
reason: 'Assinatura Digital ICP-Brasil',
location: 'Brasil',
pdf: this.pdfBase64
};
}
_bufferToBase64(buffer) {
const bytes = new Uint8Array(buffer);
let binary = '';
const len = bytes.byteLength;
for (let i = 0; i < len; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
}
export default PdfStampPicker;
+318
View File
@@ -16,6 +16,11 @@ import {
ReplayDetectedError ReplayDetectedError
} from './errors.js'; } from './errors.js';
import { PdfStampPicker } from './pdf-stamp-picker.js';
import { PdfSealDesigner } from './pdf-seal-designer.js';
export { PdfStampPicker, PdfSealDesigner };
export class CyberCoreSigner { export class CyberCoreSigner {
/** /**
* Inicializa o cliente do SDK. * Inicializa o cliente do SDK.
@@ -284,6 +289,285 @@ export class CyberCoreSigner {
}); });
} }
/**
* Obtém a lista de temas de selos visuais disponíveis e o selo padrão ativo no agente.
*
* @returns {Promise<Object>} Objeto com a lista de selos e default_seal_id.
*/
async getSealThemes() {
const resp = await this._fetch('/ccsp/v1/seals', { method: 'GET' });
return resp;
}
/**
* Assina um documento PDF completo no padrão PAdES com carimbo/selo visual estilizado.
* O PDF original pode ser fornecido como string Base64, File, Blob, ArrayBuffer ou Uint8Array.
* O CyberCore Signer aplica a assinatura incremental com estampa gráfica e retorna o PDF assinado completo em Base64.
*
* @param {Object} params Parâmetros da assinatura do documento PDF.
* @param {string|File|Blob|ArrayBuffer|Uint8Array} params.pdf PDF a ser assinado (Base64 ou arquivo binário).
* @param {string} params.certificateId Identificador ou hint do certificado digital A1/A3.
* @param {string} [params.documentName='documento.pdf'] Nome amigável do documento.
* @param {number} [params.page] Número da página (1-indexado) onde o selo deve ser posicionado.
* @param {string} [params.pageChoice='LAST_PAGE'] 'LAST_PAGE', 'FIRST_PAGE' ou null se page numérica for informada.
* @param {string} [params.position='CUSTOM'] 'BOTTOM_RIGHT', 'BOTTOM_LEFT', 'TOP_RIGHT', 'TOP_LEFT', 'CUSTOM'.
* @param {number} [params.x] Coordenada X em pontos tipográficos na página do PDF.
* @param {number} [params.y] Coordenada Y em pontos tipográficos na página do PDF.
* @param {number} [params.scale=1.0] Escala do selo (ex: 1.0 = 100%, 0.8 = 80%, 1.2 = 120%).
* @param {boolean} [params.visibleSeal=true] Se false, a assinatura PAdES não inclui estampa gráfica visível.
* @param {string} [params.sealThemeId] ID do tema do selo registrado no agente (ex: 'icp-brasil', 'gov-br').
* @param {Object} [params.sealCustom] Objeto de tema customizado inline completo.
* @param {string} [params.reason='Assinatura Digital ICP-Brasil'] Razão da assinatura.
* @param {string} [params.location='Brasil'] Localidade da assinatura.
* @param {string} [params.cpfDisplayMode] 'FULL', 'MASKED' ou 'NONE'.
* @param {boolean} [params.includeDateTime] Se deve exibir data/hora no gráfico.
* @param {string} [params.customExtraInfo] Texto extra como Cargo ou Matrícula.
* @param {number} [params.expiresInSeconds=180] Tempo de validade da requisição em segundos.
* @returns {Promise<Object>} Retorna o PDF assinado em Base64 e utilitários para download ou Blob.
*/
async signPdfDocument(params) {
if (!params) {
throw new CyberCoreError('Parâmetros de assinatura de PDF são obrigatórios.', 'BAD_REQUEST');
}
const certHint = params.certificateId || params.certificateHint;
if (!certHint) {
throw new CertificateNotFoundError('É necessário especificar o certificado digital para assinar o PDF.');
}
let pdfBase64;
if (typeof params.pdf === 'string') {
// Remove eventual prefixo data:application/pdf;base64,
pdfBase64 = params.pdf.includes(',') ? params.pdf.split(',')[1] : params.pdf;
} else if (params.pdf) {
const buffer = await this._fileToBuffer(params.pdf);
pdfBase64 = this._bufferToBase64(buffer);
}
if (!pdfBase64) {
throw new CyberCoreError('Nenhum documento PDF fornecido para assinatura.', 'BAD_REQUEST');
}
const requestId = this._generateUuid();
const nonce = this._generateUuid();
const expiresAt = new Date(Date.now() + (params.expiresInSeconds || 180) * 1000).toISOString();
const payload = {
version: '1',
request_id: requestId,
application_id: this.applicationId,
operation: 'sign_pdf',
pdf: pdfBase64,
certificate_hint: certHint,
document_name: params.documentName || params.fileName || (params.pdf?.name) || 'documento.pdf',
page: params.page !== undefined ? Number(params.page) : null,
page_choice: params.pageChoice || null,
position: params.position || ((params.x !== undefined && params.y !== undefined) ? 'CUSTOM' : 'BOTTOM_RIGHT'),
x: params.x !== undefined ? Number(params.x) : null,
y: params.y !== undefined ? Number(params.y) : null,
scale: params.scale !== undefined ? Number(params.scale) : 1.0,
visible_seal: params.visibleSeal !== undefined ? Boolean(params.visibleSeal) : true,
seal_theme_id: params.sealThemeId || params.sealTheme || null,
seal_custom: params.sealCustom || null,
reason: params.reason || 'Assinatura Digital ICP-Brasil',
location: params.location || 'Brasil',
cpf_display_mode: params.cpfDisplayMode || null,
include_date_time: params.includeDateTime !== undefined ? Boolean(params.includeDateTime) : null,
custom_extra_info: params.customExtraInfo || null,
nonce: nonce,
expires_at: expiresAt
};
try {
const resp = await this._fetch('/ccsp/v1/sign-pdf', {
method: 'POST',
body: JSON.stringify(payload)
});
if (resp.status === 'signed') {
const signedPdfBase64 = resp.signed_pdf;
const self = this;
return {
requestId: resp.request_id,
status: resp.status,
signedPdf: signedPdfBase64,
sha256: resp.sha256,
signature: resp.signature,
certificate: resp.certificate,
certificateChain: resp.certificate_chain || [resp.certificate],
algorithm: resp.algorithm,
subjectName: resp.subject_name || null,
subjectCpfCnpj: resp.subject_cpf_cnpj || null,
documentName: resp.document_name || payload.document_name,
/**
* Converte o PDF assinado em Blob binário para upload via FormData ou visualização.
* @returns {Blob}
*/
getSignedPdfBlob() {
return self._base64ToBlob(signedPdfBase64, 'application/pdf');
},
/**
* Retorna os bytes puros do PDF assinado em Uint8Array.
* @returns {Uint8Array}
*/
getSignedPdfBytes() {
return self._base64ToBytes(signedPdfBase64);
},
/**
* Dispara o download automático do PDF assinado no navegador web.
* @param {string} [filename] Nome do arquivo para download.
*/
download(filename) {
if (typeof document === 'undefined') return;
const blob = self._base64ToBlob(signedPdfBase64, 'application/pdf');
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename || resp.document_name || 'documento_assinado.pdf';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
};
}
this._throwMappedError(resp.error_code, resp.message);
} catch (err) {
if (err instanceof CyberCoreError) throw err;
throw new CyberCoreError('Erro durante a assinatura do documento PDF: ' + err.message, 'SIGN_PDF_ERROR', err);
}
}
/**
* Assina um documento XML completo no padrão XAdES-BES (ICP-Brasil) ou W3C XMLDSig Enveloped.
* O XML pode ser fornecido como string de texto puro ou arquivo/bytes.
*
* @param {Object} params Parâmetros da assinatura XML.
* @param {string|File|Blob|ArrayBuffer} params.xml Conteúdo do XML a ser assinado.
* @param {string} params.certificateId Identificador do certificado digital.
* @param {string} [params.profile='XADES_BES'] Perfil de assinatura: 'XADES_BES' ou 'XMLDSIG'.
* @param {string} [params.targetElementTag] Nome da tag alvo (ex: 'infNFe', 'infCte') ou null para o documento todo.
* @param {string} [params.targetElementId] ID do elemento alvo (ex: 'NFe3526...').
* @param {string} [params.documentName='documento.xml'] Nome amigável do arquivo XML.
* @param {string} [params.algorithm] Algoritmo XMLDSig.
* @param {number} [params.expiresInSeconds=180] Tempo de validade da requisição em segundos.
* @returns {Promise<Object>} Retorna o XML assinado como string de texto e helpers para Blob.
*/
async signXmlDocument(params) {
if (!params) {
throw new CyberCoreError('Parâmetros de assinatura XML são obrigatórios.', 'BAD_REQUEST');
}
const certHint = params.certificateId || params.certificateHint;
if (!certHint) {
throw new CertificateNotFoundError('É necessário especificar o certificado digital para assinar o XML.');
}
let xmlText = null;
let xmlBase64 = null;
if (typeof params.xml === 'string') {
xmlText = params.xml;
} else if (params.xml) {
const buffer = await this._fileToBuffer(params.xml);
xmlBase64 = this._bufferToBase64(buffer);
}
if (!xmlText && !xmlBase64) {
throw new CyberCoreError('Nenhum documento XML fornecido para assinatura.', 'BAD_REQUEST');
}
const requestId = this._generateUuid();
const nonce = this._generateUuid();
const expiresAt = new Date(Date.now() + (params.expiresInSeconds || 180) * 1000).toISOString();
const payload = {
version: '1',
request_id: requestId,
application_id: this.applicationId,
operation: 'sign_xml',
xml: xmlText,
xml_base64: xmlBase64,
certificate_hint: certHint,
document_name: params.documentName || params.fileName || (params.xml?.name) || 'documento.xml',
profile: params.profile || 'XADES_BES',
target_element_tag: params.targetElementTag || null,
target_element_id: params.targetElementId || null,
algorithm: params.algorithm || null,
nonce: nonce,
expires_at: expiresAt
};
try {
const resp = await this._fetch('/ccsp/v1/sign-xml', {
method: 'POST',
body: JSON.stringify(payload)
});
if (resp.status === 'signed') {
const signedXmlText = resp.signed_xml;
const self = this;
return {
requestId: resp.request_id,
status: resp.status,
signedXml: signedXmlText,
sha256: resp.sha256,
signature: resp.signature,
certificate: resp.certificate,
certificateChain: resp.certificate_chain || [resp.certificate],
algorithm: resp.algorithm,
subjectName: resp.subject_name || null,
subjectCpfCnpj: resp.subject_cpf_cnpj || null,
documentName: resp.document_name || payload.document_name,
/**
* Converte o XML assinado em Blob binário para upload.
* @returns {Blob}
*/
getSignedXmlBlob() {
return new Blob([signedXmlText], { type: 'application/xml;charset=utf-8' });
},
/**
* Retorna os bytes UTF-8 do XML assinado em Uint8Array.
* @returns {Uint8Array}
*/
getSignedXmlBytes() {
return new TextEncoder().encode(signedXmlText);
},
/**
* Dispara o download automático do XML assinado no navegador web.
* @param {string} [filename]
*/
download(filename) {
if (typeof document === 'undefined') return;
const blob = new Blob([signedXmlText], { type: 'application/xml;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename || resp.document_name || 'documento_assinado.xml';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
};
}
this._throwMappedError(resp.error_code, resp.message);
} catch (err) {
if (err instanceof CyberCoreError) throw err;
throw new CyberCoreError('Erro durante a assinatura do documento XML: ' + err.message, 'SIGN_XML_ERROR', err);
}
}
/** /**
* Realiza login / autenticação segura em sistemas web utilizando Certificado Digital (Challenge-Response ICP-Brasil). * Realiza login / autenticação segura em sistemas web utilizando Certificado Digital (Challenge-Response ICP-Brasil).
* Assina o desafio (challenge) emitido pelo servidor e retorna o certificado e dados decodificados do titular. * Assina o desafio (challenge) emitido pelo servidor e retorna o certificado e dados decodificados do titular.
@@ -421,6 +705,38 @@ export class CyberCoreSigner {
throw new Error('Tipo de arquivo não suportado para leitura binária.'); throw new Error('Tipo de arquivo não suportado para leitura binária.');
} }
_bufferToBase64(buffer) {
if (typeof Buffer !== 'undefined') {
return Buffer.from(buffer).toString('base64');
}
const bytes = new Uint8Array(buffer);
let binary = '';
const len = bytes.byteLength;
for (let i = 0; i < len; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
_base64ToBytes(base64) {
const clean = base64.includes(',') ? base64.split(',')[1] : base64;
if (typeof Buffer !== 'undefined') {
return new Uint8Array(Buffer.from(clean, 'base64'));
}
const binary = atob(clean);
const len = binary.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
_base64ToBlob(base64, mimeType = 'application/octet-stream') {
const bytes = this._base64ToBytes(base64);
return new Blob([bytes], { type: mimeType });
}
async _calculateSha256(data) { async _calculateSha256(data) {
let buffer; let buffer;
if (typeof data === 'string') { if (typeof data === 'string') {
@@ -492,6 +808,8 @@ export class CyberCoreSigner {
// Expõe globalmente caso seja carregado diretamente via tag <script> // Expõe globalmente caso seja carregado diretamente via tag <script>
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.CyberCoreSigner = CyberCoreSigner; window.CyberCoreSigner = CyberCoreSigner;
window.PdfStampPicker = PdfStampPicker;
window.PdfSealDesigner = PdfSealDesigner;
} }
export default CyberCoreSigner; export default CyberCoreSigner;
+127
View File
@@ -250,4 +250,131 @@ describe('@cybercore/signer-sdk - Testes Unitários do SDK', () => {
assert.equal(res.subjectCpfCnpj, '98765432100'); assert.equal(res.subjectCpfCnpj, '98765432100');
assert.equal(capturedPayload.purpose, 'AUTHENTICATION'); assert.equal(capturedPayload.purpose, 'AUTHENTICATION');
}); });
it('deve consultar lista de temas de selos com getSealThemes()', async () => {
const signer = new CyberCoreSigner();
signer._fetch = async (endpoint, options) => {
assert.equal(endpoint, '/ccsp/v1/seals');
return {
version: '1',
status: 'success',
default_seal_id: 'icp-brasil',
seals: [
{ id: 'icp-brasil', name: 'ICP-Brasil Oficial' },
{ id: 'gov-br', name: 'Gov.br Oficial' }
]
};
};
const res = await signer.getSealThemes();
assert.equal(res.status, 'success');
assert.equal(res.default_seal_id, 'icp-brasil');
assert.equal(res.seals.length, 2);
});
it('deve assinar documento PDF completo em Base64 com signPdfDocument()', async () => {
const signer = new CyberCoreSigner();
let capturedPayload = null;
let calledEndpoint = null;
const fakeSignedPdfB64 = Buffer.from('%PDF-1.4 Fake Signed PDF Content').toString('base64');
signer._fetch = async (endpoint, options) => {
calledEndpoint = endpoint;
capturedPayload = JSON.parse(options.body);
return {
version: '1',
request_id: capturedPayload.request_id,
status: 'signed',
signed_pdf: fakeSignedPdfB64,
sha256: 'FAKESHA256HASH12345',
certificate: 'FAKECERTBASE64',
certificate_chain: ['FAKECERTBASE64'],
algorithm: 'SHA256withRSA',
subject_name: 'MARIA DA SILVA',
subject_cpf_cnpj: '12345678900',
document_name: capturedPayload.document_name
};
};
const rawPdfBuffer = Buffer.from('%PDF-1.4 Document Original');
const res = await signer.signPdfDocument({
pdf: rawPdfBuffer,
certificateId: 'cert-a3-win',
documentName: 'contrato.pdf',
page: 2,
x: 350.5,
y: 50.0,
scale: 1.1,
sealThemeId: 'gov-br',
customExtraInfo: 'Matrícula: 1048'
});
assert.equal(calledEndpoint, '/ccsp/v1/sign-pdf');
assert.equal(res.status, 'signed');
assert.equal(res.signedPdf, fakeSignedPdfB64);
assert.equal(res.sha256, 'FAKESHA256HASH12345');
assert.equal(res.subjectName, 'MARIA DA SILVA');
assert.equal(res.subjectCpfCnpj, '12345678900');
assert.equal(capturedPayload.page, 2);
assert.equal(capturedPayload.x, 350.5);
assert.equal(capturedPayload.y, 50.0);
assert.equal(capturedPayload.scale, 1.1);
assert.equal(capturedPayload.seal_theme_id, 'gov-br');
assert.equal(capturedPayload.custom_extra_info, 'Matrícula: 1048');
// Valida método helper de bytes
const signedBytes = res.getSignedPdfBytes();
assert.ok(signedBytes instanceof Uint8Array);
assert.ok(signedBytes.length > 0);
});
it('deve assinar documento XML completo com signXmlDocument()', async () => {
const signer = new CyberCoreSigner();
let capturedPayload = null;
let calledEndpoint = null;
const fakeSignedXml = '<nfeProc><NFe>...</NFe><ds:Signature>...</ds:Signature></nfeProc>';
signer._fetch = async (endpoint, options) => {
calledEndpoint = endpoint;
capturedPayload = JSON.parse(options.body);
return {
version: '1',
request_id: capturedPayload.request_id,
status: 'signed',
signed_xml: fakeSignedXml,
sha256: 'XMLSHA256HASH',
certificate: 'FAKECERTBASE64',
certificate_chain: ['FAKECERTBASE64'],
algorithm: 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256',
subject_name: 'CYBERCORE LTDA',
subject_cpf_cnpj: '12345678000199',
document_name: capturedPayload.document_name
};
};
const xmlInput = '<nfeProc><NFe Id="NFe123">Dados</NFe></nfeProc>';
const res = await signer.signXmlDocument({
xml: xmlInput,
certificateId: 'cert-a1-pj',
documentName: 'nfe-123.xml',
profile: 'XADES_BES',
targetElementTag: 'NFe',
targetElementId: 'NFe123'
});
assert.equal(calledEndpoint, '/ccsp/v1/sign-xml');
assert.equal(res.status, 'signed');
assert.equal(res.signedXml, fakeSignedXml);
assert.equal(res.subjectName, 'CYBERCORE LTDA');
assert.equal(capturedPayload.profile, 'XADES_BES');
assert.equal(capturedPayload.target_element_tag, 'NFe');
assert.equal(capturedPayload.target_element_id, 'NFe123');
// Helper de bytes
const xmlBytes = res.getSignedXmlBytes();
assert.ok(xmlBytes instanceof Uint8Array);
});
}); });