feat: add SDK support for PDF document signing and visual seal customization tools
This commit is contained in:
+121
-1
@@ -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 |
|
||||
| `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 |
|
||||
| `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 |
|
||||
|
||||
---
|
||||
@@ -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
|
||||
|
||||
Quando qualquer operação falha, o servidor local CCSP/1 responde com um objeto JSON padronizado e o código de status HTTP correspondente:
|
||||
|
||||
@@ -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`.
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
## 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);
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 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);
|
||||
```
|
||||
|
||||
+64
-2
@@ -56,7 +56,67 @@ public record CcspRequest(
|
||||
String purpose,
|
||||
|
||||
@JsonProperty("challenge")
|
||||
String challenge
|
||||
String challenge,
|
||||
|
||||
@JsonProperty("pdf")
|
||||
String pdf,
|
||||
|
||||
@JsonProperty("page")
|
||||
Integer page,
|
||||
|
||||
@JsonProperty("page_choice")
|
||||
String pageChoice,
|
||||
|
||||
@JsonProperty("position")
|
||||
String position,
|
||||
|
||||
@JsonProperty("x")
|
||||
Float x,
|
||||
|
||||
@JsonProperty("y")
|
||||
Float y,
|
||||
|
||||
@JsonProperty("scale")
|
||||
Double scale,
|
||||
|
||||
@JsonProperty("visible_seal")
|
||||
Boolean visibleSeal,
|
||||
|
||||
@JsonProperty("seal_theme_id")
|
||||
String sealThemeId,
|
||||
|
||||
@JsonProperty("seal_custom")
|
||||
java.util.Map<String, Object> sealCustom,
|
||||
|
||||
@JsonProperty("reason")
|
||||
String reason,
|
||||
|
||||
@JsonProperty("location")
|
||||
String location,
|
||||
|
||||
@JsonProperty("cpf_display_mode")
|
||||
String cpfDisplayMode,
|
||||
|
||||
@JsonProperty("include_date_time")
|
||||
Boolean includeDateTime,
|
||||
|
||||
@JsonProperty("custom_extra_info")
|
||||
String customExtraInfo,
|
||||
|
||||
@JsonProperty("xml")
|
||||
String xml,
|
||||
|
||||
@JsonProperty("xml_base64")
|
||||
String xmlBase64,
|
||||
|
||||
@JsonProperty("profile")
|
||||
String profile,
|
||||
|
||||
@JsonProperty("target_element_tag")
|
||||
String targetElementTag,
|
||||
|
||||
@JsonProperty("target_element_id")
|
||||
String targetElementId
|
||||
) implements Serializable {
|
||||
|
||||
public static final String PROTOCOL_VERSION = "1";
|
||||
@@ -68,7 +128,9 @@ public record CcspRequest(
|
||||
) {
|
||||
this(version, requestId, applicationId, operation, algorithm, digest,
|
||||
certificateHint, expiresAt, timestamp, nonce, signatureType,
|
||||
null, null, null, null);
|
||||
null, null, null, null,
|
||||
null, null, null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, null, null, null, null, null);
|
||||
}
|
||||
|
||||
public boolean isVersionSupported() {
|
||||
|
||||
+69
-3
@@ -49,7 +49,16 @@ public record CcspResponse(
|
||||
String documentName,
|
||||
|
||||
@JsonProperty("document_type")
|
||||
String documentType
|
||||
String documentType,
|
||||
|
||||
@JsonProperty("signed_pdf")
|
||||
String signedPdf,
|
||||
|
||||
@JsonProperty("signed_xml")
|
||||
String signedXml,
|
||||
|
||||
@JsonProperty("sha256")
|
||||
String sha256
|
||||
) implements Serializable {
|
||||
|
||||
public CcspResponse {
|
||||
@@ -88,7 +97,58 @@ public record CcspResponse(
|
||||
subjectName,
|
||||
subjectCpfCnpj,
|
||||
documentName,
|
||||
documentType
|
||||
documentType,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
public static CcspResponse pdfSuccess(String requestId, String signedPdfBase64, String sha256,
|
||||
String signatureBase64, String certBase64, List<String> chain,
|
||||
String algorithm, String subjectName, String subjectCpfCnpj,
|
||||
String documentName) {
|
||||
return new CcspResponse(
|
||||
CcspRequest.PROTOCOL_VERSION,
|
||||
requestId,
|
||||
STATUS_SIGNED,
|
||||
signatureBase64,
|
||||
certBase64,
|
||||
chain,
|
||||
algorithm,
|
||||
null,
|
||||
null,
|
||||
subjectName,
|
||||
subjectCpfCnpj,
|
||||
documentName,
|
||||
"PDF",
|
||||
signedPdfBase64,
|
||||
null,
|
||||
sha256
|
||||
);
|
||||
}
|
||||
|
||||
public static CcspResponse xmlSuccess(String requestId, String signedXml, String sha256,
|
||||
String signatureBase64, String certBase64, List<String> chain,
|
||||
String algorithm, String subjectName, String subjectCpfCnpj,
|
||||
String documentName) {
|
||||
return new CcspResponse(
|
||||
CcspRequest.PROTOCOL_VERSION,
|
||||
requestId,
|
||||
STATUS_SIGNED,
|
||||
signatureBase64,
|
||||
certBase64,
|
||||
chain,
|
||||
algorithm,
|
||||
null,
|
||||
null,
|
||||
subjectName,
|
||||
subjectCpfCnpj,
|
||||
documentName,
|
||||
"XML",
|
||||
null,
|
||||
signedXml,
|
||||
sha256
|
||||
);
|
||||
}
|
||||
|
||||
@@ -108,7 +168,10 @@ public record CcspResponse(
|
||||
subjectName,
|
||||
subjectCpfCnpj,
|
||||
null,
|
||||
"AUTH_CHALLENGE"
|
||||
"AUTH_CHALLENGE",
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
@@ -126,6 +189,9 @@ public record CcspResponse(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
+496
@@ -5,6 +5,7 @@ import br.com.cybercore.signer.core.crypto.SigningService;
|
||||
import br.com.cybercore.signer.core.crypto.X509CertificateUtils;
|
||||
import br.com.cybercore.signer.core.domain.ManagedCertificate;
|
||||
import br.com.cybercore.signer.core.domain.SigningContext;
|
||||
import br.com.cybercore.signer.core.domain.SigningKeyMaterial;
|
||||
import br.com.cybercore.signer.core.domain.SigningResult;
|
||||
import java.security.MessageDigest;
|
||||
import br.com.cybercore.signer.core.provider.CertificateManager;
|
||||
@@ -22,6 +23,20 @@ import com.sun.net.httpserver.HttpServer;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import br.com.cybercore.signer.core.pdf.PdfSealPageChoice;
|
||||
import br.com.cybercore.signer.core.pdf.PdfSealPosition;
|
||||
import br.com.cybercore.signer.core.pdf.PdfSignatureOptions;
|
||||
import br.com.cybercore.signer.core.pdf.PdfSigningService;
|
||||
import br.com.cybercore.signer.core.pdf.seal.CpfDisplayMode;
|
||||
import br.com.cybercore.signer.core.pdf.seal.PdfSealTheme;
|
||||
import br.com.cybercore.signer.core.pdf.seal.PdfSealThemeRegistry;
|
||||
import br.com.cybercore.signer.core.xml.XmlSignatureOptions;
|
||||
import br.com.cybercore.signer.core.xml.XmlSignatureProfile;
|
||||
import br.com.cybercore.signer.core.xml.XmlSigningService;
|
||||
import java.util.Arrays;
|
||||
import java.util.UUID;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
@@ -56,6 +71,8 @@ public class CcspServer {
|
||||
private final int port;
|
||||
private final CertificateManager certificateManager;
|
||||
private final SigningService signingService;
|
||||
private final PdfSigningService pdfSigningService;
|
||||
private final XmlSigningService xmlSigningService;
|
||||
private final ApplicationRegistry applicationRegistry;
|
||||
private final NonceManager nonceManager;
|
||||
private final OriginValidator originValidator;
|
||||
@@ -75,6 +92,8 @@ public class CcspServer {
|
||||
this.port = port;
|
||||
this.certificateManager = Objects.requireNonNull(certificateManager, "certificateManager cannot be null");
|
||||
this.signingService = Objects.requireNonNull(signingService, "signingService cannot be null");
|
||||
this.pdfSigningService = new PdfSigningService(this.certificateManager);
|
||||
this.xmlSigningService = new XmlSigningService(this.certificateManager);
|
||||
this.applicationRegistry = applicationRegistry != null ? applicationRegistry : ApplicationRegistry.getInstance();
|
||||
this.promptListener = promptListener;
|
||||
this.nonceManager = new NonceManager();
|
||||
@@ -114,6 +133,9 @@ public class CcspServer {
|
||||
httpServer.createContext("/ccsp/v1/sign", new SignHandler());
|
||||
httpServer.createContext("/ccsp/v1/authenticate", new SignHandler());
|
||||
httpServer.createContext("/ccsp/v1/authorize", new AuthorizeHandler());
|
||||
httpServer.createContext("/ccsp/v1/sign-pdf", new SignPdfHandler());
|
||||
httpServer.createContext("/ccsp/v1/sign-xml", new SignXmlHandler());
|
||||
httpServer.createContext("/ccsp/v1/seals", new SealsHandler());
|
||||
|
||||
httpServer.start();
|
||||
running = true;
|
||||
@@ -458,6 +480,480 @@ public class CcspServer {
|
||||
}
|
||||
}
|
||||
|
||||
private class SignPdfHandler implements HttpHandler {
|
||||
@Override
|
||||
public void handle(HttpExchange exchange) throws IOException {
|
||||
if (handleCors(exchange)) return;
|
||||
|
||||
if (!"POST".equalsIgnoreCase(exchange.getRequestMethod())) {
|
||||
sendErrorResponse(exchange, 405, "METHOD_NOT_ALLOWED", "Apenas POST é suportado.");
|
||||
return;
|
||||
}
|
||||
|
||||
String origin = getOrigin(exchange);
|
||||
CcspRequest request;
|
||||
try (InputStream is = exchange.getRequestBody()) {
|
||||
request = objectMapper.readValue(is, CcspRequest.class);
|
||||
} catch (Exception e) {
|
||||
sendCcspError(exchange, 400, null, CcspErrorCode.SIGNATURE_FAILED, "Payload de requisição inválido.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!request.isVersionSupported()) {
|
||||
sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.COMMUNICATION_ERROR,
|
||||
"Versão do protocolo não suportada: " + request.version());
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.isExpired()) {
|
||||
sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.REQUEST_EXPIRED,
|
||||
"A requisição de assinatura expirou.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.nonce() != null && !request.nonce().isBlank()) {
|
||||
boolean accepted = nonceManager.registerNonce(request.nonce());
|
||||
if (!accepted) {
|
||||
sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.REPLAY_DETECTED,
|
||||
"Requisição duplicada detectada (replay prevenido).");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
String appId = request.applicationId();
|
||||
if (appId == null || appId.isBlank()) {
|
||||
sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.APPLICATION_NOT_AUTHORIZED,
|
||||
"application_id é obrigatório.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!applicationRegistry.isAuthorized(appId, origin)) {
|
||||
boolean approved = promptApplicationAuthorization(origin, "Aplicação Web", appId);
|
||||
if (!approved) {
|
||||
sendCcspError(exchange, 403, request.requestId(), CcspErrorCode.APPLICATION_NOT_AUTHORIZED,
|
||||
"Aplicação não autorizada pelo usuário.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (request.pdf() == null || request.pdf().isBlank()) {
|
||||
sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.SIGNATURE_FAILED,
|
||||
"O campo 'pdf' contendo o documento em Base64 é obrigatório.");
|
||||
return;
|
||||
}
|
||||
|
||||
byte[] pdfBytes;
|
||||
try {
|
||||
pdfBytes = Base64.getDecoder().decode(request.pdf());
|
||||
} catch (Exception e) {
|
||||
sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.SIGNATURE_FAILED,
|
||||
"O campo 'pdf' não contém dados Base64 válidos.");
|
||||
return;
|
||||
}
|
||||
|
||||
Optional<ManagedCertificate> certOpt = certificateManager.findByHint(request.certificateHint());
|
||||
if (certOpt.isEmpty()) {
|
||||
sendCcspError(exchange, 404, request.requestId(), CcspErrorCode.CERTIFICATE_NOT_FOUND,
|
||||
"Nenhum certificado correspondente encontrado.");
|
||||
return;
|
||||
}
|
||||
ManagedCertificate cert = certOpt.get();
|
||||
|
||||
if (promptListener != null) {
|
||||
try {
|
||||
CompletableFuture<Boolean> approvalFuture = promptListener.requestSignatureConfirmation(request, cert);
|
||||
Boolean confirmed = approvalFuture.get(60, TimeUnit.SECONDS);
|
||||
if (!Boolean.TRUE.equals(confirmed)) {
|
||||
sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.USER_CANCELLED,
|
||||
"Operação de assinatura cancelada pelo usuário.");
|
||||
return;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.warn("Falha ou timeout na confirmação visual de assinatura: {}", e.getMessage());
|
||||
sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.USER_CANCELLED,
|
||||
"Confirmação da assinatura expirou ou foi cancelada.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
PdfSealTheme sealTheme;
|
||||
if (request.sealCustom() != null && !request.sealCustom().isEmpty()) {
|
||||
sealTheme = parseCustomSealTheme(request.sealCustom());
|
||||
} else if (request.sealThemeId() != null && !request.sealThemeId().isBlank()) {
|
||||
sealTheme = PdfSealThemeRegistry.findById(request.sealThemeId())
|
||||
.orElse(PdfSealThemeRegistry.getDefaultTheme());
|
||||
} else {
|
||||
sealTheme = PdfSealThemeRegistry.getDefaultTheme();
|
||||
}
|
||||
|
||||
PdfSealPosition position = PdfSealPosition.BOTTOM_RIGHT;
|
||||
if (request.x() != null && request.y() != null) {
|
||||
position = PdfSealPosition.CUSTOM;
|
||||
} else if (request.position() != null) {
|
||||
try {
|
||||
position = PdfSealPosition.valueOf(request.position().toUpperCase());
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
PdfSealPageChoice pageChoice = PdfSealPageChoice.LAST_PAGE;
|
||||
if (request.pageChoice() != null) {
|
||||
try {
|
||||
pageChoice = PdfSealPageChoice.valueOf(request.pageChoice().toUpperCase());
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
CpfDisplayMode cpfMode = sealTheme.defaultCpfMode();
|
||||
if (request.cpfDisplayMode() != null) {
|
||||
try {
|
||||
cpfMode = CpfDisplayMode.valueOf(request.cpfDisplayMode().toUpperCase());
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
boolean includeDateTime = request.includeDateTime() != null
|
||||
? request.includeDateTime()
|
||||
: sealTheme.defaultIncludeDateTime();
|
||||
|
||||
boolean visibleSeal = request.visibleSeal() == null || Boolean.TRUE.equals(request.visibleSeal());
|
||||
double customScale = request.scale() != null && request.scale() > 0 ? request.scale() : 1.0;
|
||||
|
||||
PdfSignatureOptions options = new PdfSignatureOptions(
|
||||
cert.id(),
|
||||
null,
|
||||
visibleSeal,
|
||||
sealTheme,
|
||||
position,
|
||||
pageChoice,
|
||||
request.reason() != null ? request.reason() : "Assinatura Digital de Documento",
|
||||
request.location() != null ? request.location() : "Brasil",
|
||||
request.x(),
|
||||
request.y(),
|
||||
request.page(),
|
||||
cpfMode,
|
||||
includeDateTime,
|
||||
request.customExtraInfo(),
|
||||
customScale
|
||||
);
|
||||
|
||||
signLock.lock();
|
||||
try {
|
||||
byte[] signedPdfBytes = pdfSigningService.signPdf(pdfBytes, options);
|
||||
String signedPdfBase64 = Base64.getEncoder().encodeToString(signedPdfBytes);
|
||||
|
||||
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||
byte[] shaBytes = md.digest(signedPdfBytes);
|
||||
String shaHex = bytesToHex(shaBytes);
|
||||
|
||||
Optional<SigningKeyMaterial> keyMat = certificateManager.getKeyMaterial(cert.id(), null);
|
||||
String certBase64 = null;
|
||||
List<String> chainBase64 = Collections.emptyList();
|
||||
if (keyMat.isPresent()) {
|
||||
try {
|
||||
certBase64 = Base64.getEncoder().encodeToString(keyMat.get().signingCertificate().getEncoded());
|
||||
chainBase64 = Arrays.stream(keyMat.get().certificateChain())
|
||||
.map(c -> {
|
||||
try {
|
||||
return Base64.getEncoder().encodeToString(c.getEncoded());
|
||||
} catch (Exception e) {
|
||||
return "";
|
||||
}
|
||||
})
|
||||
.filter(s -> !s.isEmpty())
|
||||
.toList();
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
String subjectName = X509CertificateUtils.extractHolderName(cert.commonName());
|
||||
String cpfCnpj = cert.cpf() != null ? cert.cpf() : cert.cnpj();
|
||||
|
||||
CcspResponse response = CcspResponse.pdfSuccess(
|
||||
request.requestId(),
|
||||
signedPdfBase64,
|
||||
shaHex,
|
||||
null,
|
||||
certBase64,
|
||||
chainBase64,
|
||||
"SHA256withRSA",
|
||||
subjectName,
|
||||
cpfCnpj,
|
||||
request.documentName() != null ? request.documentName() : "documento.pdf"
|
||||
);
|
||||
|
||||
sendJsonResponse(exchange, 200, response);
|
||||
|
||||
} catch (CertificateProviderException cpe) {
|
||||
sendCcspError(exchange, 400, request.requestId(), cpe.getErrorCode(), cpe.getMessage());
|
||||
} catch (Exception e) {
|
||||
logger.error("Falha ao assinar PDF: {}", e.getMessage(), e);
|
||||
sendCcspError(exchange, 500, request.requestId(), CcspErrorCode.SIGNATURE_FAILED,
|
||||
"Erro ao assinar PDF: " + e.getMessage());
|
||||
} finally {
|
||||
signLock.unlock();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class SignXmlHandler implements HttpHandler {
|
||||
@Override
|
||||
public void handle(HttpExchange exchange) throws IOException {
|
||||
if (handleCors(exchange)) return;
|
||||
|
||||
if (!"POST".equalsIgnoreCase(exchange.getRequestMethod())) {
|
||||
sendErrorResponse(exchange, 405, "METHOD_NOT_ALLOWED", "Apenas POST é suportado.");
|
||||
return;
|
||||
}
|
||||
|
||||
String origin = getOrigin(exchange);
|
||||
CcspRequest request;
|
||||
try (InputStream is = exchange.getRequestBody()) {
|
||||
request = objectMapper.readValue(is, CcspRequest.class);
|
||||
} catch (Exception e) {
|
||||
sendCcspError(exchange, 400, null, CcspErrorCode.SIGNATURE_FAILED, "Payload de requisição inválido.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!request.isVersionSupported()) {
|
||||
sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.COMMUNICATION_ERROR,
|
||||
"Versão do protocolo não suportada: " + request.version());
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.isExpired()) {
|
||||
sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.REQUEST_EXPIRED,
|
||||
"A requisição de assinatura expirou.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.nonce() != null && !request.nonce().isBlank()) {
|
||||
boolean accepted = nonceManager.registerNonce(request.nonce());
|
||||
if (!accepted) {
|
||||
sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.REPLAY_DETECTED,
|
||||
"Requisição duplicada detectada (replay prevenido).");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
String appId = request.applicationId();
|
||||
if (appId == null || appId.isBlank()) {
|
||||
sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.APPLICATION_NOT_AUTHORIZED,
|
||||
"application_id é obrigatório.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!applicationRegistry.isAuthorized(appId, origin)) {
|
||||
boolean approved = promptApplicationAuthorization(origin, "Aplicação Web", appId);
|
||||
if (!approved) {
|
||||
sendCcspError(exchange, 403, request.requestId(), CcspErrorCode.APPLICATION_NOT_AUTHORIZED,
|
||||
"Aplicação não autorizada pelo usuário.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
byte[] xmlBytes;
|
||||
if (request.xmlBase64() != null && !request.xmlBase64().isBlank()) {
|
||||
try {
|
||||
xmlBytes = Base64.getDecoder().decode(request.xmlBase64());
|
||||
} catch (Exception e) {
|
||||
sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.SIGNATURE_FAILED,
|
||||
"O campo 'xml_base64' não contém dados válidos.");
|
||||
return;
|
||||
}
|
||||
} else if (request.xml() != null && !request.xml().isBlank()) {
|
||||
xmlBytes = request.xml().getBytes(StandardCharsets.UTF_8);
|
||||
} else {
|
||||
sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.SIGNATURE_FAILED,
|
||||
"É obrigatório fornecer o XML em texto ('xml') ou Base64 ('xml_base64').");
|
||||
return;
|
||||
}
|
||||
|
||||
Optional<ManagedCertificate> certOpt = certificateManager.findByHint(request.certificateHint());
|
||||
if (certOpt.isEmpty()) {
|
||||
sendCcspError(exchange, 404, request.requestId(), CcspErrorCode.CERTIFICATE_NOT_FOUND,
|
||||
"Nenhum certificado correspondente encontrado.");
|
||||
return;
|
||||
}
|
||||
ManagedCertificate cert = certOpt.get();
|
||||
|
||||
if (promptListener != null) {
|
||||
try {
|
||||
CompletableFuture<Boolean> approvalFuture = promptListener.requestSignatureConfirmation(request, cert);
|
||||
Boolean confirmed = approvalFuture.get(60, TimeUnit.SECONDS);
|
||||
if (!Boolean.TRUE.equals(confirmed)) {
|
||||
sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.USER_CANCELLED,
|
||||
"Operação de assinatura cancelada pelo usuário.");
|
||||
return;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.warn("Falha ou timeout na confirmação visual de assinatura XML: {}", e.getMessage());
|
||||
sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.USER_CANCELLED,
|
||||
"Confirmação da assinatura expirou ou foi cancelada.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
XmlSignatureProfile profile = XmlSignatureProfile.XADES_BES;
|
||||
if (request.profile() != null) {
|
||||
try {
|
||||
profile = XmlSignatureProfile.valueOf(request.profile().toUpperCase());
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
String algo = request.algorithm() != null && !request.algorithm().isBlank()
|
||||
? request.algorithm()
|
||||
: XmlSignatureOptions.DEFAULT_ALGORITHM;
|
||||
|
||||
XmlSignatureOptions options = new XmlSignatureOptions(
|
||||
cert.id(),
|
||||
null,
|
||||
request.targetElementTag(),
|
||||
request.targetElementId(),
|
||||
algo,
|
||||
profile
|
||||
);
|
||||
|
||||
signLock.lock();
|
||||
try {
|
||||
byte[] signedXmlBytes = xmlSigningService.signXml(xmlBytes, options);
|
||||
String signedXmlStr = new String(signedXmlBytes, StandardCharsets.UTF_8);
|
||||
|
||||
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||
byte[] shaBytes = md.digest(signedXmlBytes);
|
||||
String shaHex = bytesToHex(shaBytes);
|
||||
|
||||
Optional<SigningKeyMaterial> keyMat = certificateManager.getKeyMaterial(cert.id(), null);
|
||||
String certBase64 = null;
|
||||
List<String> chainBase64 = Collections.emptyList();
|
||||
if (keyMat.isPresent()) {
|
||||
try {
|
||||
certBase64 = Base64.getEncoder().encodeToString(keyMat.get().signingCertificate().getEncoded());
|
||||
chainBase64 = Arrays.stream(keyMat.get().certificateChain())
|
||||
.map(c -> {
|
||||
try {
|
||||
return Base64.getEncoder().encodeToString(c.getEncoded());
|
||||
} catch (Exception e) {
|
||||
return "";
|
||||
}
|
||||
})
|
||||
.filter(s -> !s.isEmpty())
|
||||
.toList();
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
String subjectName = X509CertificateUtils.extractHolderName(cert.commonName());
|
||||
String cpfCnpj = cert.cpf() != null ? cert.cpf() : cert.cnpj();
|
||||
|
||||
CcspResponse response = CcspResponse.xmlSuccess(
|
||||
request.requestId(),
|
||||
signedXmlStr,
|
||||
shaHex,
|
||||
null,
|
||||
certBase64,
|
||||
chainBase64,
|
||||
algo,
|
||||
subjectName,
|
||||
cpfCnpj,
|
||||
request.documentName() != null ? request.documentName() : "documento.xml"
|
||||
);
|
||||
|
||||
sendJsonResponse(exchange, 200, response);
|
||||
|
||||
} catch (CertificateProviderException cpe) {
|
||||
sendCcspError(exchange, 400, request.requestId(), cpe.getErrorCode(), cpe.getMessage());
|
||||
} catch (Exception e) {
|
||||
logger.error("Falha ao assinar XML: {}", e.getMessage(), e);
|
||||
sendCcspError(exchange, 500, request.requestId(), CcspErrorCode.SIGNATURE_FAILED,
|
||||
"Erro ao assinar XML: " + e.getMessage());
|
||||
} finally {
|
||||
signLock.unlock();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class SealsHandler implements HttpHandler {
|
||||
@Override
|
||||
public void handle(HttpExchange exchange) throws IOException {
|
||||
if (handleCors(exchange)) return;
|
||||
|
||||
if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) {
|
||||
sendErrorResponse(exchange, 405, "METHOD_NOT_ALLOWED", "Apenas GET é suportado.");
|
||||
return;
|
||||
}
|
||||
|
||||
List<Map<String, Object>> sealList = PdfSealThemeRegistry.listThemes().stream().map(t -> {
|
||||
Map<String, Object> dto = new HashMap<>();
|
||||
dto.put("id", t.id());
|
||||
dto.put("name", t.name());
|
||||
dto.put("description", t.description());
|
||||
dto.put("width", t.width());
|
||||
dto.put("height", t.height());
|
||||
dto.put("primary_color", t.primaryColor());
|
||||
dto.put("secondary_color", t.secondaryColor());
|
||||
dto.put("background_color", t.backgroundColor());
|
||||
dto.put("border_color", t.borderColor());
|
||||
dto.put("text_color", t.textColor());
|
||||
dto.put("icon_style", t.iconStyle());
|
||||
dto.put("is_custom", t.isCustom());
|
||||
dto.put("default_cpf_mode", t.defaultCpfMode() != null ? t.defaultCpfMode().name() : "FULL");
|
||||
dto.put("default_include_date_time", t.defaultIncludeDateTime());
|
||||
return dto;
|
||||
}).toList();
|
||||
|
||||
Map<String, Object> resp = new HashMap<>();
|
||||
resp.put("version", CcspRequest.PROTOCOL_VERSION);
|
||||
resp.put("status", "success");
|
||||
resp.put("default_seal_id", PdfSealThemeRegistry.getDefaultTheme().id());
|
||||
resp.put("seals", sealList);
|
||||
|
||||
sendJsonResponse(exchange, 200, resp);
|
||||
}
|
||||
}
|
||||
|
||||
private PdfSealTheme parseCustomSealTheme(Map<String, Object> map) {
|
||||
if (map == null || map.isEmpty()) {
|
||||
return PdfSealThemeRegistry.getDefaultTheme();
|
||||
}
|
||||
String id = "custom-" + UUID.randomUUID().toString().substring(0, 8);
|
||||
String name = (String) map.getOrDefault("name", "Selo Personalizado");
|
||||
String description = (String) map.getOrDefault("description", "Selo customizado via aplicação web");
|
||||
int width = map.containsKey("width") ? ((Number) map.get("width")).intValue() : 320;
|
||||
int height = map.containsKey("height") ? ((Number) map.get("height")).intValue() : 82;
|
||||
String primaryColor = (String) map.getOrDefault("primaryColor", "#0d47a1");
|
||||
String secondaryColor = (String) map.getOrDefault("secondaryColor", "#0284c7");
|
||||
String backgroundColor = (String) map.getOrDefault("backgroundColor", "#ffffff");
|
||||
String borderColor = (String) map.getOrDefault("borderColor", "#cbd5e1");
|
||||
String textColor = (String) map.getOrDefault("textColor", "#0f172a");
|
||||
String customHeader = (String) map.get("customHeader");
|
||||
String customFooter = (String) map.get("customFooter");
|
||||
String iconStyle = (String) map.getOrDefault("iconStyle", "shield");
|
||||
String customLogoBase64 = (String) map.get("customLogoBase64");
|
||||
String customFieldLabel = (String) map.get("customFieldLabel");
|
||||
String customFieldValue = (String) map.get("customFieldValue");
|
||||
|
||||
CpfDisplayMode cpfMode = CpfDisplayMode.FULL;
|
||||
if (map.containsKey("defaultCpfMode") && map.get("defaultCpfMode") != null) {
|
||||
try {
|
||||
cpfMode = CpfDisplayMode.valueOf(map.get("defaultCpfMode").toString().toUpperCase());
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
boolean includeDateTime = !map.containsKey("defaultIncludeDateTime") || Boolean.TRUE.equals(map.get("defaultIncludeDateTime"));
|
||||
|
||||
return new PdfSealTheme(
|
||||
id, name, description, width, height,
|
||||
primaryColor, secondaryColor, backgroundColor, borderColor, textColor,
|
||||
null, customHeader, customFooter, iconStyle,
|
||||
cpfMode, includeDateTime, true, customLogoBase64,
|
||||
customFieldLabel, customFieldValue
|
||||
);
|
||||
}
|
||||
|
||||
private static String bytesToHex(byte[] bytes) {
|
||||
if (bytes == null) return "";
|
||||
StringBuilder sb = new StringBuilder(bytes.length * 2);
|
||||
for (byte b : bytes) {
|
||||
sb.append(String.format("%02x", b));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Utilitários de Segurança e HTTP
|
||||
// =========================================================================
|
||||
|
||||
+241
-3
@@ -11,22 +11,43 @@ import br.com.cybercore.signer.core.provider.CertificateProvider;
|
||||
import br.com.cybercore.signer.core.provider.ProviderType;
|
||||
import br.com.cybercore.signer.core.security.ApplicationRegistry;
|
||||
import br.com.cybercore.signer.core.security.AuthorizedApplication;
|
||||
import br.com.cybercore.signer.core.domain.SigningKeyMaterial;
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType1Font;
|
||||
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
|
||||
import org.bouncycastle.asn1.x500.X500Name;
|
||||
import org.bouncycastle.cert.X509v3CertificateBuilder;
|
||||
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
|
||||
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
|
||||
import org.bouncycastle.operator.ContentSigner;
|
||||
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.math.BigInteger;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Base64;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
@@ -49,6 +70,28 @@ class CcspServerTest {
|
||||
SigningService signingService = mock(SigningService.class);
|
||||
ApplicationRegistry appRegistry = new ApplicationRegistry(tempDir.resolve("apps.json"));
|
||||
|
||||
// Gera par de chaves e certificado real em memória para suporte a PDF e XML
|
||||
KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");
|
||||
kpg.initialize(2048);
|
||||
KeyPair kp = kpg.generateKeyPair();
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
Date notBefore = new Date(now - 100000);
|
||||
Date notAfter = new Date(now + 365L * 24 * 3600 * 1000);
|
||||
|
||||
X500Name subject = new X500Name("CN=Teste ICP-Brasil:12345678900, O=CyberCore Testes, C=BR");
|
||||
X509v3CertificateBuilder certBuilder = new JcaX509v3CertificateBuilder(
|
||||
subject,
|
||||
BigInteger.valueOf(System.currentTimeMillis()),
|
||||
notBefore,
|
||||
notAfter,
|
||||
subject,
|
||||
kp.getPublic()
|
||||
);
|
||||
|
||||
ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA").build(kp.getPrivate());
|
||||
X509Certificate cert = new JcaX509CertificateConverter().getCertificate(certBuilder.build(signer));
|
||||
|
||||
// Registra uma aplicação de teste
|
||||
appRegistry.register(new AuthorizedApplication(
|
||||
"app-test",
|
||||
@@ -64,8 +107,8 @@ class CcspServerTest {
|
||||
"AliasTeste",
|
||||
CertificateType.A1,
|
||||
CertificateStatus.AVAILABLE,
|
||||
"CN=Teste ICP-Brasil, C=BR",
|
||||
"CN=AC Raiz, C=BR",
|
||||
"CN=Teste ICP-Brasil:12345678900, O=CyberCore Testes, C=BR",
|
||||
"CN=Teste ICP-Brasil:12345678900, O=CyberCore Testes, C=BR",
|
||||
"12345678",
|
||||
"a1b2c3",
|
||||
"d4e5f6",
|
||||
@@ -89,6 +132,9 @@ class CcspServerTest {
|
||||
@Override public SigningResult sign(SigningContext context) {
|
||||
return new SigningResult(context.requestId(), new byte[]{1, 2, 3}, "mock-cert", List.of("mock-cert"), "SHA256withRSA", Instant.now());
|
||||
}
|
||||
@Override public Optional<SigningKeyMaterial> getKeyMaterial(String certificateId, char[] pinOrPassword) {
|
||||
return Optional.of(new SigningKeyMaterial(kp.getPrivate(), new X509Certificate[]{cert}, null));
|
||||
}
|
||||
};
|
||||
|
||||
certManager.registerProvider(dummyProvider);
|
||||
@@ -149,7 +195,7 @@ class CcspServerTest {
|
||||
HttpResponse<String> resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
assertThat(resp.statusCode()).isEqualTo(200);
|
||||
assertThat(resp.body()).contains("\"status\":\"success\"");
|
||||
assertThat(resp.body()).contains("CN=Teste ICP-Brasil, C=BR");
|
||||
assertThat(resp.body()).contains("Teste ICP-Brasil");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -320,4 +366,196 @@ class CcspServerTest {
|
||||
assertThat(response.body()).contains("\"document_name\":\"proposta_comercial.pdf\"");
|
||||
assertThat(response.body()).contains("\"document_type\":\"PDF\"");
|
||||
}
|
||||
|
||||
private byte[] createSamplePdf() throws Exception {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage page = new PDPage();
|
||||
doc.addPage(page);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.beginText();
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA_BOLD), 12);
|
||||
cs.newLineAtOffset(50, 700);
|
||||
cs.showText("PDF de Teste CCSP - CyberCore Signer");
|
||||
cs.endText();
|
||||
}
|
||||
doc.save(baos);
|
||||
}
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Endpoint /seals deve listar temas de selos visuais disponíveis")
|
||||
void testGetSealsThemes() throws Exception {
|
||||
HttpRequest req = HttpRequest.newBuilder()
|
||||
.uri(URI.create(baseUrl + "/ccsp/v1/seals?application_id=app-test"))
|
||||
.header("Origin", "https://meusistema.com.br")
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
HttpResponse<String> resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
assertThat(resp.statusCode()).isEqualTo(200);
|
||||
assertThat(resp.body()).contains("\"status\":\"success\"");
|
||||
assertThat(resp.body()).contains("\"seals\":");
|
||||
assertThat(resp.body()).contains("icp-brasil");
|
||||
assertThat(resp.body()).contains("gov-br");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Endpoint /sign-pdf deve assinar documento PDF em Base64 e retornar signed_pdf válido")
|
||||
void testSignPdfSuccess() throws Exception {
|
||||
byte[] pdfBytes = createSamplePdf();
|
||||
String pdfBase64 = Base64.getEncoder().encodeToString(pdfBytes);
|
||||
|
||||
String jsonPayload = """
|
||||
{
|
||||
"version": "1",
|
||||
"request_id": "%s",
|
||||
"application_id": "app-test",
|
||||
"operation": "sign-pdf",
|
||||
"certificate_hint": "cert-1",
|
||||
"pdf": "%s",
|
||||
"page": 1,
|
||||
"x": 60.0,
|
||||
"y": 60.0,
|
||||
"scale": 1.0,
|
||||
"seal_theme_id": "icp-brasil",
|
||||
"reason": "Aprovado e assinado via CCSP",
|
||||
"location": "Curitiba, PR",
|
||||
"nonce": "%s"
|
||||
}
|
||||
""".formatted(UUID.randomUUID(), pdfBase64, UUID.randomUUID());
|
||||
|
||||
HttpRequest req = HttpRequest.newBuilder()
|
||||
.uri(URI.create(baseUrl + "/ccsp/v1/sign-pdf"))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Origin", "https://meusistema.com.br")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(jsonPayload))
|
||||
.build();
|
||||
|
||||
HttpResponse<String> resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
assertThat(resp.statusCode()).isEqualTo(200);
|
||||
assertThat(resp.body()).contains("\"status\":\"signed\"");
|
||||
assertThat(resp.body()).contains("\"signed_pdf\":");
|
||||
assertThat(resp.body()).contains("\"sha256\":");
|
||||
|
||||
// Verifica que o PDF assinado retornado pode ser carregado pelo PDFBox
|
||||
int signedPdfIdx = resp.body().indexOf("\"signed_pdf\":\"") + 14;
|
||||
int endIdx = resp.body().indexOf("\"", signedPdfIdx);
|
||||
String signedPdfBase64 = resp.body().substring(signedPdfIdx, endIdx);
|
||||
|
||||
byte[] signedBytes = Base64.getDecoder().decode(signedPdfBase64);
|
||||
try (PDDocument signedDoc = Loader.loadPDF(signedBytes)) {
|
||||
assertThat(signedDoc.getNumberOfPages()).isEqualTo(1);
|
||||
assertThat(signedDoc.getSignatureDictionaries()).isNotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Endpoint /sign-pdf com seal_custom inline deve assinar com sucesso")
|
||||
void testSignPdfCustomSealSuccess() throws Exception {
|
||||
byte[] pdfBytes = createSamplePdf();
|
||||
String pdfBase64 = Base64.getEncoder().encodeToString(pdfBytes);
|
||||
|
||||
String jsonPayload = """
|
||||
{
|
||||
"version": "1",
|
||||
"request_id": "%s",
|
||||
"application_id": "app-test",
|
||||
"operation": "sign-pdf",
|
||||
"certificate_hint": "cert-1",
|
||||
"pdf": "%s",
|
||||
"page": 1,
|
||||
"x": 40.0,
|
||||
"y": 40.0,
|
||||
"scale": 0.9,
|
||||
"seal_custom": {
|
||||
"name": "Custom Seal Test",
|
||||
"title": "SELADO DIGITALMENTE",
|
||||
"primary_color": "#2c3e50",
|
||||
"accent_color": "#18bc9c",
|
||||
"show_timestamp": true,
|
||||
"show_document_hash": true
|
||||
},
|
||||
"nonce": "%s"
|
||||
}
|
||||
""".formatted(UUID.randomUUID(), pdfBase64, UUID.randomUUID());
|
||||
|
||||
HttpRequest req = HttpRequest.newBuilder()
|
||||
.uri(URI.create(baseUrl + "/ccsp/v1/sign-pdf"))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Origin", "https://meusistema.com.br")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(jsonPayload))
|
||||
.build();
|
||||
|
||||
HttpResponse<String> resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
assertThat(resp.statusCode()).isEqualTo(200);
|
||||
assertThat(resp.body()).contains("\"status\":\"signed\"");
|
||||
assertThat(resp.body()).contains("\"signed_pdf\":");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Endpoint /sign-xml deve assinar XML plano com perfil XMLDSig")
|
||||
void testSignXmlSuccess() throws Exception {
|
||||
String xmlContent = "<documento id=\"doc-123\"><dados>Contrato de Teste</dados></documento>";
|
||||
|
||||
String jsonPayload = """
|
||||
{
|
||||
"version": "1",
|
||||
"request_id": "%s",
|
||||
"application_id": "app-test",
|
||||
"operation": "sign-xml",
|
||||
"certificate_hint": "cert-1",
|
||||
"xml": "%s",
|
||||
"profile": "XMLDSIG",
|
||||
"nonce": "%s"
|
||||
}
|
||||
""".formatted(UUID.randomUUID(), xmlContent.replace("\"", "\\\""), UUID.randomUUID());
|
||||
|
||||
HttpRequest req = HttpRequest.newBuilder()
|
||||
.uri(URI.create(baseUrl + "/ccsp/v1/sign-xml"))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Origin", "https://meusistema.com.br")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(jsonPayload))
|
||||
.build();
|
||||
|
||||
HttpResponse<String> resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
assertThat(resp.statusCode()).isEqualTo(200);
|
||||
assertThat(resp.body()).contains("\"status\":\"signed\"");
|
||||
assertThat(resp.body()).contains("\"signed_xml\":");
|
||||
assertThat(resp.body()).contains("SignatureValue");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Endpoint /sign-xml deve assinar XML em Base64 com perfil XAdES-BES")
|
||||
void testSignXmlBase64XadesBes() throws Exception {
|
||||
String xmlContent = "<pedido id=\"ped-999\"><valor>150.00</valor></pedido>";
|
||||
String xmlBase64 = Base64.getEncoder().encodeToString(xmlContent.getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
String jsonPayload = """
|
||||
{
|
||||
"version": "1",
|
||||
"request_id": "%s",
|
||||
"application_id": "app-test",
|
||||
"operation": "sign-xml",
|
||||
"certificate_hint": "cert-1",
|
||||
"xml_base64": "%s",
|
||||
"profile": "XADES_BES",
|
||||
"nonce": "%s"
|
||||
}
|
||||
""".formatted(UUID.randomUUID(), xmlBase64, UUID.randomUUID());
|
||||
|
||||
HttpRequest req = HttpRequest.newBuilder()
|
||||
.uri(URI.create(baseUrl + "/ccsp/v1/sign-xml"))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Origin", "https://meusistema.com.br")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(jsonPayload))
|
||||
.build();
|
||||
|
||||
HttpResponse<String> resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
assertThat(resp.statusCode()).isEqualTo(200);
|
||||
assertThat(resp.body()).contains("\"status\":\"signed\"");
|
||||
assertThat(resp.body()).contains("\"signed_xml\":");
|
||||
assertThat(resp.body()).contains("QualifyingProperties");
|
||||
}
|
||||
}
|
||||
|
||||
+419
-110
@@ -7,6 +7,12 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<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">
|
||||
<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>
|
||||
:root {
|
||||
--bg-base: #090d16;
|
||||
@@ -615,10 +621,13 @@
|
||||
<!-- Abas de Navegação -->
|
||||
<div class="tabs-nav">
|
||||
<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 class="tab-btn" data-tab="tab-xml">
|
||||
<span>📋</span> Assinar XML (NF-e)
|
||||
<span>📋</span> Assinar XML (XAdES / NF-e)
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="tab-file">
|
||||
<span>📁</span> Arquivos em Geral
|
||||
@@ -628,19 +637,19 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ABA 1: ASSINAR PDF -->
|
||||
<!-- ABA 1: ASSINAR PDF (INTERATIVO COM RETORNO EM BASE64) -->
|
||||
<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-header">
|
||||
<span class="card-title"><span>📄</span> Seleção do Documento PDF</span>
|
||||
<span class="badge-tag">PAdES / Digest</span>
|
||||
<span class="card-title"><span>📄</span> Seleção do Documento & Posicionador de Selo (Stamp Picker)</span>
|
||||
<span class="badge-tag">PAdES ISO 32000 / Base64</span>
|
||||
</div>
|
||||
|
||||
<div id="pdfDropZone" class="drop-zone">
|
||||
<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: 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;">
|
||||
</div>
|
||||
|
||||
@@ -649,80 +658,157 @@
|
||||
<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>
|
||||
<span class="badge-tag">PDF</span>
|
||||
<span class="badge-tag">PDF Carregado</span>
|
||||
</div>
|
||||
|
||||
<!-- Contêiner do PdfStampPicker -->
|
||||
<div id="stampPickerMount" style="margin-top: 14px; display: none;"></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">Digest SHA-256 Calculado no Navegador (Base64):</label>
|
||||
<div id="pdfDigestBox" class="code-box">Selecione um arquivo PDF para calcular o digest...</div>
|
||||
<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>
|
||||
|
||||
<button id="btnSignPdf" class="btn btn-primary" style="padding: 12px;" disabled>
|
||||
<span>✍️</span> Assinar PDF com CyberCore
|
||||
<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>
|
||||
|
||||
<div id="pdfAlert" class="alert alert-info" style="display: none;"></div>
|
||||
<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 id="pdfAlert" class="alert alert-info" style="display: none; margin-top: 14px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Card de Resultado do PDF Assinado -->
|
||||
<div id="pdfResultCard" class="card" style="display: none;">
|
||||
<div class="card-header">
|
||||
<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 class="form-group">
|
||||
<label class="form-label">Conteúdo do PDF Assinado em Base64 (pronto para envio ao Backend):</label>
|
||||
<div id="pdfResultBase64" class="code-box" style="max-height: 140px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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> Resultado da Assinatura Digital</span>
|
||||
<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 class="form-group">
|
||||
<label class="form-label">Assinatura Digital Retornada (Base64):</label>
|
||||
<div id="pdfSigOutput" class="code-box" style="min-height: 120px;">Aguardando assinatura do PDF...</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 id="sealDesignerMount"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ABA 2: ASSINAR XML -->
|
||||
<!-- ABA 3: ASSINAR XML -->
|
||||
<div id="tab-xml" class="tab-pane">
|
||||
<div class="card-grid">
|
||||
<div class="card">
|
||||
<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;">
|
||||
Carregar Exemplo NF-e
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Estrutura XML para Assinatura:</label>
|
||||
<textarea id="xmlContentInput" rows="7" placeholder="Cole o XML ou selecione um arquivo..."></textarea>
|
||||
<label class="form-label">Perfil de Assinatura XML:</label>
|
||||
<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 class="form-group">
|
||||
<label class="form-label">Digest SHA-256 Calculado no Navegador (Base64):</label>
|
||||
<div id="xmlDigestBox" class="code-box">Aguardando XML...</div>
|
||||
<label class="form-label">Estrutura XML para Assinatura:</label>
|
||||
<textarea id="xmlContentInput" rows="8" placeholder="Cole o XML ou selecione um arquivo..."></textarea>
|
||||
</div>
|
||||
|
||||
<button id="btnSignXml" class="btn btn-primary" style="padding: 12px;" disabled>
|
||||
<span>✍️</span> Assinar XML com CyberCore
|
||||
<div style="display: flex; gap: 10px; margin-top: 4px;">
|
||||
<button id="btnSignXmlFull" class="btn btn-primary" style="flex: 2; padding: 12px;" disabled>
|
||||
<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 class="card">
|
||||
<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 class="form-group">
|
||||
<label class="form-label">Bloco SignatureValue (Base64):</label>
|
||||
<div id="xmlSigOutput" class="code-box" style="min-height: 120px;">Aguardando assinatura do XML...</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Tag XMLDSig Incorporável:</label>
|
||||
<div id="xmlTagOutput" class="code-box"><Signature xmlns="http://www.w3.org/2000/09/xmldsig#">...</Signature></div>
|
||||
<label class="form-label">XML Assinado Completo (com envelope de assinatura):</label>
|
||||
<div id="xmlSigOutput" class="code-box" style="min-height: 220px; max-height: 380px;">Aguardando submissão do XML para assinatura...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -851,7 +937,7 @@
|
||||
|
||||
<!-- Lógica Javascript com o SDK -->
|
||||
<script type="module">
|
||||
import { CyberCoreSigner } from '../src/signer-sdk.js';
|
||||
import { CyberCoreSigner, PdfStampPicker, PdfSealDesigner } from '../src/signer-sdk.js';
|
||||
|
||||
const signer = new CyberCoreSigner({
|
||||
baseUrl: 'http://127.0.0.1:21325',
|
||||
@@ -870,6 +956,16 @@
|
||||
let currentCertificates = [];
|
||||
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
|
||||
const tabButtons = document.querySelectorAll('.tab-btn');
|
||||
const tabPanes = document.querySelectorAll('.tab-pane');
|
||||
@@ -879,7 +975,13 @@
|
||||
tabButtons.forEach(b => b.classList.remove('active'));
|
||||
tabPanes.forEach(p => p.classList.remove('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;
|
||||
}
|
||||
|
||||
// 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
|
||||
async function loadCertificates() {
|
||||
try {
|
||||
@@ -913,7 +1038,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
certs.forEach((c, index) => {
|
||||
certs.forEach((c) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = c.id;
|
||||
opt.textContent = `${c.common_name || c.alias} [${c.type}] (${c.device_name || c.provider_id})`;
|
||||
@@ -949,10 +1074,24 @@
|
||||
|
||||
function updateButtonStates() {
|
||||
const hasCert = Boolean(selectedCertificateId);
|
||||
document.getElementById('btnSignPdf').disabled = !hasCert || !currentPdfDigest;
|
||||
document.getElementById('btnSignXml').disabled = !hasCert || !currentXmlDigest;
|
||||
document.getElementById('btnSignGen').disabled = !hasCert || !currentGenDigest;
|
||||
document.getElementById('btnAuthLogin').disabled = !hasCert;
|
||||
const hasPdf = Boolean(currentPdfBytes);
|
||||
const hasXml = Boolean(document.getElementById('xmlContentInput').value.trim());
|
||||
|
||||
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
|
||||
@@ -964,6 +1103,7 @@
|
||||
statusDot.classList.add('online');
|
||||
statusText.textContent = 'Conectado & Autorizado';
|
||||
await loadCertificates();
|
||||
await loadSealThemes();
|
||||
} catch (err) {
|
||||
alert('Erro ao conectar: ' + err.message);
|
||||
} finally {
|
||||
@@ -976,25 +1116,31 @@
|
||||
const online = await checkAgentStatus();
|
||||
if (online) {
|
||||
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 pdfFileInput = document.getElementById('pdfFileInput');
|
||||
const pdfFileMeta = document.getElementById('pdfFileMeta');
|
||||
const pdfFileName = document.getElementById('pdfFileName');
|
||||
const pdfFileSize = document.getElementById('pdfFileSize');
|
||||
const pdfDigestBox = document.getElementById('pdfDigestBox');
|
||||
const btnSignPdf = document.getElementById('btnSignPdf');
|
||||
const stampPickerMount = document.getElementById('stampPickerMount');
|
||||
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 pdfSigOutput = document.getElementById('pdfSigOutput');
|
||||
const pdfCertOutput = document.getElementById('pdfCertOutput');
|
||||
|
||||
let currentPdfFile = null;
|
||||
let currentPdfDigest = null;
|
||||
const pdfResultCard = document.getElementById('pdfResultCard');
|
||||
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());
|
||||
pdfFileInput.addEventListener('change', e => {
|
||||
@@ -1019,17 +1165,84 @@
|
||||
pdfFileMeta.style.display = 'flex';
|
||||
|
||||
const buffer = await file.arrayBuffer();
|
||||
currentPdfBytes = 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();
|
||||
}
|
||||
|
||||
btnSignPdf.addEventListener('click', async () => {
|
||||
btnSignPdf.disabled = true;
|
||||
btnSignPdf.innerHTML = '<span class="spinner"></span> Solicitando no Agente...';
|
||||
// Assinatura Completa de PDF (Devolve PDF Assinado Base64)
|
||||
btnSignPdfFull.addEventListener('click', async () => {
|
||||
btnSignPdfFull.disabled = true;
|
||||
btnSignPdfFull.innerHTML = '<span class="spinner"></span> Assinando no Agente...';
|
||||
pdfAlert.style.display = 'block';
|
||||
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 {
|
||||
const result = await signer.signPdf(currentPdfFile, {
|
||||
@@ -1038,30 +1251,97 @@
|
||||
});
|
||||
|
||||
pdfAlert.className = 'alert alert-success';
|
||||
pdfAlert.textContent = `✓ Documento PDF assinado com sucesso via ${result.algorithm}!`;
|
||||
pdfSigOutput.textContent = result.signature;
|
||||
pdfCertOutput.textContent = result.certificate;
|
||||
pdfAlert.textContent = `✓ Digest assinado com sucesso! Algoritmo: ${result.algorithm}`;
|
||||
} catch (err) {
|
||||
pdfAlert.className = 'alert alert-error';
|
||||
pdfAlert.textContent = 'Erro ao assinar PDF: ' + err.message;
|
||||
pdfAlert.textContent = 'Erro ao assinar digest: ' + err.message;
|
||||
} finally {
|
||||
btnSignPdf.disabled = false;
|
||||
btnSignPdf.innerHTML = '<span>✍️</span> Assinar PDF com CyberCore';
|
||||
btnSignPdfDigest.disabled = false;
|
||||
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 xmlDigestBox = document.getElementById('xmlDigestBox');
|
||||
const btnSignXml = document.getElementById('btnSignXml');
|
||||
const xmlProfileSelect = document.getElementById('xmlProfileSelect');
|
||||
const btnSignXmlFull = document.getElementById('btnSignXmlFull');
|
||||
const btnSignXmlDigest = document.getElementById('btnSignXmlDigest');
|
||||
const btnLoadXmlSample = document.getElementById('btnLoadXmlSample');
|
||||
const xmlAlert = document.getElementById('xmlAlert');
|
||||
const xmlSigOutput = document.getElementById('xmlSigOutput');
|
||||
const xmlTagOutput = document.getElementById('xmlTagOutput');
|
||||
|
||||
let currentXmlDigest = null;
|
||||
const btnDownloadXml = document.getElementById('btnDownloadXml');
|
||||
const btnCopyXml = document.getElementById('btnCopyXml');
|
||||
|
||||
const sampleXml = `<nfeProc versao="4.00" xmlns="http://www.portalfiscal.inf.br/nfe">
|
||||
<NFe>
|
||||
@@ -1083,30 +1363,52 @@
|
||||
</NFe>
|
||||
</nfeProc>`;
|
||||
|
||||
async function updateXmlDigest() {
|
||||
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);
|
||||
xmlContentInput.addEventListener('input', () => updateButtonStates());
|
||||
btnLoadXmlSample.addEventListener('click', () => {
|
||||
xmlContentInput.value = sampleXml;
|
||||
updateXmlDigest();
|
||||
updateButtonStates();
|
||||
});
|
||||
|
||||
btnSignXml.addEventListener('click', async () => {
|
||||
btnSignXml.disabled = true;
|
||||
btnSignXml.innerHTML = '<span class="spinner"></span> Solicitando no Agente...';
|
||||
// Assinatura Completa de XML
|
||||
btnSignXmlFull.addEventListener('click', async () => {
|
||||
btnSignXmlFull.disabled = true;
|
||||
btnSignXmlFull.innerHTML = '<span class="spinner"></span> Assinando no Agente...';
|
||||
xmlAlert.style.display = 'block';
|
||||
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 {
|
||||
const result = await signer.signXml(xmlContentInput.value, {
|
||||
@@ -1115,33 +1417,40 @@
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
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) {
|
||||
xmlAlert.className = 'alert alert-error';
|
||||
xmlAlert.textContent = 'Erro ao assinar XML: ' + err.message;
|
||||
xmlAlert.textContent = 'Erro ao assinar digest do XML: ' + err.message;
|
||||
} finally {
|
||||
btnSignXml.disabled = false;
|
||||
btnSignXml.innerHTML = '<span>✍️</span> Assinar XML com CyberCore';
|
||||
btnSignXmlDigest.disabled = false;
|
||||
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
|
||||
// =========================================================================
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -16,6 +16,11 @@ import {
|
||||
ReplayDetectedError
|
||||
} from './errors.js';
|
||||
|
||||
import { PdfStampPicker } from './pdf-stamp-picker.js';
|
||||
import { PdfSealDesigner } from './pdf-seal-designer.js';
|
||||
|
||||
export { PdfStampPicker, PdfSealDesigner };
|
||||
|
||||
export class CyberCoreSigner {
|
||||
/**
|
||||
* 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).
|
||||
* 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.');
|
||||
}
|
||||
|
||||
_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) {
|
||||
let buffer;
|
||||
if (typeof data === 'string') {
|
||||
@@ -492,6 +808,8 @@ export class CyberCoreSigner {
|
||||
// Expõe globalmente caso seja carregado diretamente via tag <script>
|
||||
if (typeof window !== 'undefined') {
|
||||
window.CyberCoreSigner = CyberCoreSigner;
|
||||
window.PdfStampPicker = PdfStampPicker;
|
||||
window.PdfSealDesigner = PdfSealDesigner;
|
||||
}
|
||||
|
||||
export default CyberCoreSigner;
|
||||
|
||||
@@ -250,4 +250,131 @@ describe('@cybercore/signer-sdk - Testes Unitários do SDK', () => {
|
||||
assert.equal(res.subjectCpfCnpj, '98765432100');
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user