/** * CyberCore Signer Web SDK (@cybercore/signer-sdk) * Protocolo: CCSP/1 (CyberCore Signing Protocol v1) */ import { CyberCoreError, AgentNotRunningError, ApplicationNotAuthorizedError, UserCancelledError, CertificateNotFoundError, PinRequiredError, PinIncorrectError, TokenNotFoundError, RequestExpiredError, ReplayDetectedError } from './errors.js'; export class CyberCoreSigner { /** * Inicializa o cliente do SDK. * * @param {Object} options Configurações do SDK. * @param {string} [options.baseUrl='http://127.0.0.1:21325'] URL base do agente local. * @param {string} [options.applicationId] UUID único da aplicação web (gerado se não fornecido). * @param {string} [options.applicationName] Nome legível da aplicação apresentado ao usuário. */ constructor(options = {}) { this.baseUrl = (options.baseUrl || 'http://127.0.0.1:21325').replace(/\/+$/, ''); 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.message?.includes('Failed to fetch') || err.message?.includes('NetworkError')) { throw new AgentNotRunningError(); } 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 resp = await this._fetch('/ccsp/v1/certificates', { 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' }); } /** * 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 // ========================================================================= async _fileToBuffer(file) { if (file instanceof ArrayBuffer) 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.'); } 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 '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': throw new TokenNotFoundError(message); case 'REQUEST_EXPIRED': throw new RequestExpiredError(message); case 'REPLAY_DETECTED': throw new ReplayDetectedError(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