diff --git a/docs/CCSP.md b/docs/CCSP.md index 0ef3c4f..7496d5c 100644 --- a/docs/CCSP.md +++ b/docs/CCSP.md @@ -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": "...", + "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": "...", + "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: diff --git a/docs/pades.md b/docs/pades.md index b299fba..e8da082 100644 --- a/docs/pades.md +++ b/docs/pades.md @@ -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); +``` diff --git a/docs/xml-xmldsig.md b/docs/xml-xmldsig.md index 940e38a..40507b0 100644 --- a/docs/xml-xmldsig.md +++ b/docs/xml-xmldsig.md @@ -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); +``` diff --git a/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/protocol/CcspRequest.java b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/protocol/CcspRequest.java index f744e37..c0ffd94 100644 --- a/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/protocol/CcspRequest.java +++ b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/protocol/CcspRequest.java @@ -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 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() { diff --git a/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/protocol/CcspResponse.java b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/protocol/CcspResponse.java index 26aeb88..8263fbf 100644 --- a/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/protocol/CcspResponse.java +++ b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/protocol/CcspResponse.java @@ -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 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 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 ); } diff --git a/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/protocol/CcspServer.java b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/protocol/CcspServer.java index 6a760d2..70d1f7b 100644 --- a/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/protocol/CcspServer.java +++ b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/protocol/CcspServer.java @@ -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 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 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 keyMat = certificateManager.getKeyMaterial(cert.id(), null); + String certBase64 = null; + List 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 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 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 keyMat = certificateManager.getKeyMaterial(cert.id(), null); + String certBase64 = null; + List 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> sealList = PdfSealThemeRegistry.listThemes().stream().map(t -> { + Map 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 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 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 // ========================================================================= diff --git a/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/protocol/CcspServerTest.java b/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/protocol/CcspServerTest.java index 65bc161..05d10ba 100644 --- a/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/protocol/CcspServerTest.java +++ b/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/protocol/CcspServerTest.java @@ -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 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 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 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 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 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 = "Contrato de Teste"; + + 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 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 = "150.00"; + 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 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"); + } } diff --git a/sdk/demo/index.html b/sdk/demo/index.html index fdb67ef..6518b4d 100644 --- a/sdk/demo/index.html +++ b/sdk/demo/index.html @@ -7,6 +7,12 @@ + +