945 lines
34 KiB
JavaScript
945 lines
34 KiB
JavaScript
/**
|
|
* 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<boolean>} 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<Object>} 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<boolean>} 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<Array<Object>>} 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<Object>} 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<Object>} 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<Object>}
|
|
*/
|
|
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<Object>}
|
|
*/
|
|
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<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.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<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.
|
|
*
|
|
* @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<Object>} 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<boolean>} 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<Object>} 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 <script>
|
|
if (typeof window !== 'undefined') {
|
|
window.CyberCoreSigner = CyberCoreSigner;
|
|
window.PdfStampPicker = PdfStampPicker;
|
|
window.PdfSealDesigner = PdfSealDesigner;
|
|
}
|
|
|
|
export default CyberCoreSigner;
|