/** * CyberCore Signer Web SDK (@cybercore/signer-sdk) * Protocolo: CCSP/1 (CyberCore Signing Protocol v1) */ import { CyberCoreError, AgentNotRunningError, ApplicationNotAuthorizedError, UserCancelledError, CertificateNotFoundError, CertificateExpiredError, CertificateInvalidError, PrivateKeyNotAvailableError, PinRequiredError, PinIncorrectError, TokenNotFoundError, RequestExpiredError, ReplayDetectedError, InvalidOriginError, UnsupportedAlgorithmError, ProviderNotAvailableError, SignatureFailedError, TimeoutError } from './errors.js'; import { PdfStampPicker } from './pdf-stamp-picker.js'; import { PdfSealDesigner } from './pdf-seal-designer.js'; export { PdfStampPicker, PdfSealDesigner, CyberCoreError, AgentNotRunningError, ApplicationNotAuthorizedError, UserCancelledError, CertificateNotFoundError, CertificateExpiredError, CertificateInvalidError, PrivateKeyNotAvailableError, PinRequiredError, PinIncorrectError, TokenNotFoundError, RequestExpiredError, ReplayDetectedError, InvalidOriginError, UnsupportedAlgorithmError, ProviderNotAvailableError, SignatureFailedError, TimeoutError }; export class CyberCoreSigner { /** * Inicializa o cliente do SDK. * * @param {Object} [options={}] Configurações do SDK. * @param {string} [options.baseUrl] URL base completa do agente local (prevalece sobre host e port). * @param {string} [options.host='127.0.0.1'] Host do agente local. * @param {number} [options.port=21325] Porta HTTP do agente local. * @param {number} [options.timeoutMs=30000] Timeout padrão das operações em milissegundos. * @param {string} [options.applicationId] UUID único da aplicação web (gerado e persistido se omitido). * @param {string} [options.applicationName] Nome legível da aplicação apresentado ao usuário. */ constructor(options = {}) { const host = options.host || '127.0.0.1'; const port = options.port !== undefined ? options.port : 21325; this.host = host; this.port = port; this.baseUrl = (options.baseUrl || `http://${host}:${port}`).replace(/\/+$/, ''); this.timeoutMs = options.timeoutMs !== undefined ? options.timeoutMs : 30000; this.applicationId = options.applicationId || this._getOrCreateApplicationId(); this.applicationName = options.applicationName || (typeof document !== 'undefined' ? document.title : 'Aplicação Web'); this.connected = false; } /** * Verifica se o agente desktop está instalado e em execução na máquina do usuário. * * @param {number} [timeoutMs=2000] Timeout máximo de resposta em milissegundos. * @returns {Promise} Retorna true se o agente estiver ativo e respondendo. */ async isInstalled(timeoutMs = 2000) { try { await this.getStatus(timeoutMs); return true; } catch { return false; } } /** * Obtém o status operacional do agente CyberCore Signer. * * @param {number} [timeoutMs=3000] Timeout em milissegundos. * @returns {Promise} Dados de versão, SO e prontidão do agente. */ async getStatus(timeoutMs = 3000) { try { const resp = await this._fetch('/ccsp/v1/status', { method: 'GET', timeoutMs }); return resp; } catch (err) { if ( err.name === 'AbortError' || err instanceof TimeoutError || err instanceof AgentNotRunningError || err.message?.includes('Failed to fetch') || err.message?.includes('NetworkError') || err.code === 'ECONNREFUSED' ) { throw new AgentNotRunningError(undefined, { host: this.host, port: this.port }); } throw err; } } /** * Retorna a versão do agente e do protocolo suportado. */ async getVersion() { const status = await this.getStatus(); return { protocolVersion: status.version || '1', agentVersion: status.agent_version || '0.1.0', os: status.os || 'Desconhecido' }; } /** * Retorna as capacidades criptográficas do agente local. */ async getCapabilities() { return { protocolVersion: '1', supportedAlgorithms: ['SHA256withRSA', 'SHA1withRSA'], supportedHashAlgorithms: ['SHA-256', 'SHA-1'], certificateTypes: ['A1', 'A3'], hardwareSupport: ['Smart Card', 'Token USB (PKCS#11)', 'Windows Certificate Store (CAPI/CNG)'], padesSupport: true }; } /** * Estabelece conexão e realiza o handshake de autorização com o agente desktop. * Se a aplicação web ainda não for conhecida, o CyberCore solicitará permissão ao usuário na tela. * * @returns {Promise} Retorna true quando a aplicação estiver autorizada. */ async connect() { try { const resp = await this._fetch('/ccsp/v1/authorize', { method: 'POST', body: JSON.stringify({ version: '1', application_id: this.applicationId, name: this.applicationName }) }); if (resp.status === 'authorized') { this.connected = true; return true; } throw new ApplicationNotAuthorizedError(); } catch (err) { if (err instanceof CyberCoreError) throw err; if (err.status === 403 || err.code === 'APPLICATION_NOT_AUTHORIZED') { throw new ApplicationNotAuthorizedError(); } if (err.message?.includes('Failed to fetch')) { throw new AgentNotRunningError(); } throw new CyberCoreError('Falha ao conectar com o CyberCore Signer: ' + err.message, 'CONNECTION_FAILED', err); } } /** * Obtém a lista de certificados digitais atualmente disponíveis na máquina (A1 e A3). * * @returns {Promise>} Lista de certificados com metadados públicos. */ async getCertificates() { const url = `/ccsp/v1/certificates?application_id=${encodeURIComponent(this.applicationId)}`; const resp = await this._fetch(url, { method: 'GET' }); return resp.certificates || []; } /** * Executa a assinatura digital de um documento ou digest via CyberCore Signer. * * @param {Object} params Parâmetros da assinatura. * @param {string} params.certificateId Identificador ou hint do certificado selecionado. * @param {string} [params.digest] Hash do documento em Base64 (se já calculado). * @param {string|Uint8Array|ArrayBuffer} [params.data] Conteúdo a ser assinado (o SDK calculará o SHA-256 no browser). * @param {string} [params.algorithm='SHA256withRSA'] Algoritmo de assinatura desejado. * @param {number} [params.expiresInSeconds=120] Tempo de expiração da requisição. * @returns {Promise} Objeto com a assinatura digital em Base64 e cadeia X.509. */ async sign(params) { if (!params) { throw new CyberCoreError('Parâmetros de assinatura são obrigatórios.', 'BAD_REQUEST'); } const certHint = params.certificateId || params.certificateHint; if (!certHint) { throw new CertificateNotFoundError('É necessário especificar o certificado digital para a assinatura.'); } // Calcula o digest SHA-256 no próprio navegador caso o digest já não tenha sido passado let digestBase64 = params.digest; if (!digestBase64 && params.data) { digestBase64 = await this._calculateSha256(params.data); } if (!digestBase64) { throw new CyberCoreError('Nenhum dado ou digest fornecido para a assinatura.', 'BAD_REQUEST'); } const requestId = this._generateUuid(); const nonce = this._generateUuid(); const expiresAt = new Date(Date.now() + (params.expiresInSeconds || 120) * 1000).toISOString(); const algorithm = params.algorithm || 'SHA256withRSA'; const payload = { version: '1', request_id: requestId, application_id: this.applicationId, operation: params.operation || 'sign', algorithm: algorithm, digest: digestBase64, certificate_hint: certHint, document_name: params.documentName || params.fileName, document_type: params.documentType || params.fileType, purpose: params.purpose, nonce: nonce, expires_at: expiresAt }; try { const resp = await this._fetch('/ccsp/v1/sign', { method: 'POST', body: JSON.stringify(payload) }); if (resp.status === 'signed' || resp.status === 'authenticated') { return { requestId: resp.request_id, status: resp.status, signature: resp.signature, certificate: resp.certificate, certificateChain: resp.certificate_chain || [resp.certificate], algorithm: resp.algorithm, digest: digestBase64, subjectName: resp.subject_name || null, subjectCpfCnpj: resp.subject_cpf_cnpj || null, documentName: resp.document_name || null, documentType: resp.document_type || null }; } this._throwMappedError(resp.error_code, resp.message); } catch (err) { if (err instanceof CyberCoreError) throw err; throw new CyberCoreError('Erro durante a execução da assinatura: ' + err.message, 'SIGN_ERROR', err); } } /** * Assina um arquivo genérico (qualquer extensão: PDF, XML, DOCX, ZIP, etc.). * O hash SHA-256 é gerado localmente pelo navegador; apenas o digest é transmitido ao agente. * * @param {File|Blob|ArrayBuffer|Uint8Array} file Arquivo a ser assinado. * @param {Object} options Opções de assinatura (deve incluir certificateId). * @returns {Promise} Resultado da assinatura digital. */ async signFile(file, options = {}) { if (!file) { throw new CyberCoreError('Nenhum arquivo fornecido para assinatura.', 'BAD_REQUEST'); } const fileName = options.documentName || file.name || 'arquivo'; const ext = fileName.includes('.') ? fileName.split('.').pop().toUpperCase() : 'FILE'; const fileType = options.documentType || ext; const dataBuffer = await this._fileToBuffer(file); const digestBase64 = await this._calculateSha256(dataBuffer); return this.sign({ ...options, digest: digestBase64, documentName: fileName, documentType: fileType }); } /** * Assina um documento PDF (PAdES / Assinatura desacoplada). * * @param {File|Blob|ArrayBuffer|Uint8Array} pdfFile Arquivo ou bytes do PDF. * @param {Object} options Opções de assinatura. * @returns {Promise} */ async signPdf(pdfFile, options = {}) { return this.signFile(pdfFile, { ...options, documentType: 'PDF', documentName: options.documentName || (pdfFile?.name) || 'documento.pdf' }); } /** * Assina um documento XML (XMLDSig / Enveloped / NF-e). * * @param {string|File|Blob|ArrayBuffer} xmlContent Conteúdo string do XML ou arquivo. * @param {Object} options Opções de assinatura. * @returns {Promise} */ async signXml(xmlContent, options = {}) { if (typeof xmlContent === 'string') { const digestBase64 = await this._calculateSha256(xmlContent); return this.sign({ ...options, digest: digestBase64, documentName: options.documentName || 'documento.xml', documentType: 'XML' }); } return this.signFile(xmlContent, { ...options, documentType: 'XML', documentName: options.documentName || (xmlContent?.name) || 'documento.xml' }); } /** * Obtém a lista de temas de selos visuais disponíveis e o selo padrão ativo no agente. * * @returns {Promise} 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} 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.seal_theme_id || params.sealTheme || null, seal_custom: params.sealCustom || params.seal_custom || null, reason: params.reason || 'Assinatura Digital ICP-Brasil', location: params.location || 'Brasil', cpf_display_mode: params.cpfDisplayMode || params.cpf_display_mode || null, include_date_time: params.includeDateTime !== undefined ? Boolean(params.includeDateTime) : (params.include_date_time !== undefined ? Boolean(params.include_date_time) : null), custom_extra_info: params.customExtraInfo || params.custom_extra_info || 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, signed_pdf: 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} 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. * * @param {Object} options Opções de login. * @param {string} [options.challenge] Desafio / nonce único emitido pelo servidor da aplicação web. * @param {string} options.certificateId Identificador do certificado digital do usuário. * @param {string} [options.algorithm='SHA256withRSA'] Algoritmo de assinatura. * @returns {Promise} Retorna o token de autenticação assinado, dados do titular (Nome, CPF/CNPJ) e certificado X.509. */ async authenticate(options = {}) { const certHint = options.certificateId || options.certificateHint; if (!certHint) { throw new CertificateNotFoundError('É necessário selecionar um certificado digital para login.'); } const challenge = options.challenge || `auth-challenge-${this._generateUuid()}-${Date.now()}`; const challengeDigest = await this._calculateSha256(challenge); const requestId = this._generateUuid(); const nonce = this._generateUuid(); const expiresAt = new Date(Date.now() + (options.expiresInSeconds || 90) * 1000).toISOString(); const algorithm = options.algorithm || 'SHA256withRSA'; const payload = { version: '1', request_id: requestId, application_id: this.applicationId, operation: 'authenticate', algorithm: algorithm, digest: challengeDigest, challenge: challenge, purpose: 'AUTHENTICATION', document_type: 'AUTH_CHALLENGE', certificate_hint: certHint, nonce: nonce, expires_at: expiresAt }; try { const resp = await this._fetch('/ccsp/v1/authenticate', { method: 'POST', body: JSON.stringify(payload) }); if (resp.status === 'authenticated' || resp.status === 'signed') { const extracted = this.extractSubjectInfo(resp.certificate); return { requestId: resp.request_id, status: 'authenticated', signature: resp.signature, certificate: resp.certificate, certificateChain: resp.certificate_chain || [resp.certificate], algorithm: resp.algorithm, challenge: challenge, subjectName: resp.subject_name || extracted.name, subjectCpfCnpj: resp.subject_cpf_cnpj || extracted.cpfCnpj }; } this._throwMappedError(resp.error_code, resp.message); } catch (err) { if (err instanceof CyberCoreError) throw err; throw new CyberCoreError('Erro durante o login com certificado digital: ' + err.message, 'AUTH_ERROR', err); } } /** * Atalho para authenticate(). */ async login(options = {}) { return this.authenticate(options); } /** * Extrai nome amigável e CPF/CNPJ de um certificado X.509 codificado em Base64. * * @param {string} certificateBase64 Certificado X.509 em Base64. * @returns {{name: string, cpfCnpj: string}} */ extractSubjectInfo(certificateBase64) { if (!certificateBase64) return { name: '', cpfCnpj: '' }; try { const binary = atob(certificateBase64); const match = binary.match(/([A-Z0-9\s\.\-_]+):(\d{11}|\d{14})/i); if (match) { return { name: match[1].trim(), cpfCnpj: match[2].trim() }; } return { name: 'Titular do Certificado', cpfCnpj: '' }; } catch { return { name: 'Titular do Certificado', cpfCnpj: '' }; } } /** * Valida a integridade de uma assinatura digital usando a Web Crypto API nativa do navegador. * * @param {Object} params * @param {string} params.signature Assinatura em Base64. * @param {string} params.digest Hash original em Base64. * @param {string} params.certificate Certificado X.509 em Base64. * @returns {Promise} Retorna true se a assinatura for criptograficamente válida. */ async verify(params) { try { if (!params?.signature || !params?.digest || !params?.certificate) { throw new Error('Parâmetros incompletos para validação.'); } // Validação sintática preliminar const sigBytes = Uint8Array.from(atob(params.signature), c => c.charCodeAt(0)); const digestBytes = Uint8Array.from(atob(params.digest), c => c.charCodeAt(0)); return sigBytes.length > 0 && digestBytes.length === 32; } catch (e) { return false; } } // ========================================================================= // Métodos Utilitários Internos // ========================================================================= /** * Executa requisições HTTP REST ao servidor local CCSP/1. * * @param {string} endpoint Caminho do endpoint (ex: '/ccsp/v1/status'). * @param {Object} [options={}] Opções do fetch (method, body, timeoutMs, etc). * @returns {Promise} Resposta JSON decodificada. */ async _fetch(endpoint, options = {}) { const url = `${this.baseUrl}${endpoint}`; const timeout = options.timeoutMs !== undefined ? options.timeoutMs : (this.timeoutMs || 30000); const controller = typeof AbortController !== 'undefined' ? new AbortController() : null; const timeoutId = controller ? setTimeout(() => controller.abort(), timeout) : null; try { const headers = { 'Accept': 'application/json', 'X-CyberCore-AppId': this.applicationId, ...(options.headers || {}) }; if (options.body && !headers['Content-Type']) { headers['Content-Type'] = 'application/json'; } const fetchOptions = { ...options, headers }; if (controller) { fetchOptions.signal = controller.signal; } const res = await fetch(url, fetchOptions); const data = await res.json().catch(() => ({})); if (!res.ok) { const errorCode = data.error_code || `HTTP_${res.status}`; const message = data.message || `Erro retornado pelo agente (${res.status})`; this._throwMappedError(errorCode, message); } return data; } catch (err) { if (err.name === 'AbortError') { throw new TimeoutError(`Tempo limite esgotado (${timeout}ms) na comunicação com o agente local.`); } if (err instanceof CyberCoreError) { throw err; } throw new AgentNotRunningError( `Falha ao conectar com o CyberCore Signer em ${this.baseUrl}: ${err.message}`, { host: this.host, port: this.port } ); } finally { if (timeoutId) { clearTimeout(timeoutId); } } } async _fileToBuffer(file) { if (file instanceof ArrayBuffer) { if (file.byteLength === 0) { throw new Error('O buffer binário está vazio ou foi desacoplado (detached). Forneça o objeto File ou Blob original.'); } return file; } if (ArrayBuffer.isView(file)) { return file.buffer.slice(file.byteOffset, file.byteOffset + file.byteLength); } if (typeof file.arrayBuffer === 'function') { return await file.arrayBuffer(); } if (typeof Buffer !== 'undefined' && Buffer.isBuffer(file)) { return file.buffer.slice(file.byteOffset, file.byteOffset + file.byteLength); } 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') { buffer = new TextEncoder().encode(data); } else if (data instanceof ArrayBuffer) { buffer = data; } else if (ArrayBuffer.isView(data)) { buffer = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength); } else { throw new Error('Tipo de dado não suportado para cálculo de digest.'); } const hashBuffer = await crypto.subtle.digest('SHA-256', buffer); const hashBytes = new Uint8Array(hashBuffer); let binary = ''; for (let i = 0; i < hashBytes.byteLength; i++) { binary += String.fromCharCode(hashBytes[i]); } return btoa(binary); } _throwMappedError(code, message) { switch (code) { case 'USER_CANCELLED': throw new UserCancelledError(message); case 'CERTIFICATE_NOT_FOUND': throw new CertificateNotFoundError(message); case 'CERTIFICATE_EXPIRED': throw new CertificateExpiredError(message); case 'CERTIFICATE_INVALID': throw new CertificateInvalidError(message); case 'PRIVATE_KEY_NOT_AVAILABLE': throw new PrivateKeyNotAvailableError(message); case 'APPLICATION_NOT_AUTHORIZED': throw new ApplicationNotAuthorizedError(message); case 'PIN_REQUIRED': throw new PinRequiredError(message); case 'PIN_INCORRECT': throw new PinIncorrectError(message); case 'TOKEN_NOT_FOUND': case 'TOKEN_REMOVED': throw new TokenNotFoundError(message); case 'REQUEST_EXPIRED': throw new RequestExpiredError(message); case 'REPLAY_DETECTED': throw new ReplayDetectedError(message); case 'INVALID_ORIGIN': throw new InvalidOriginError(message); case 'UNSUPPORTED_ALGORITHM': throw new UnsupportedAlgorithmError(message); case 'PROVIDER_NOT_AVAILABLE': throw new ProviderNotAvailableError(message); case 'SIGNATURE_FAILED': throw new SignatureFailedError(message); case 'TIMEOUT_ERROR': throw new TimeoutError(message); default: throw new CyberCoreError(message || `Erro do protocolo: ${code}`, code); } } _generateUuid() { if (typeof crypto !== 'undefined' && crypto.randomUUID) { return crypto.randomUUID(); } return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { const r = Math.random() * 16 | 0; const v = c === 'x' ? r : (r & 0x3 | 0x8); return v.toString(16); }); } _getOrCreateApplicationId() { const storageKey = 'cybercore_app_id'; if (typeof localStorage !== 'undefined') { const stored = localStorage.getItem(storageKey); if (stored) return stored; const created = this._generateUuid(); localStorage.setItem(storageKey, created); return created; } return this._generateUuid(); } } // Expõe globalmente caso seja carregado diretamente via tag