feat: initial commit - cybercore signer desktop agent

This commit is contained in:
Claudecio Martins
2026-10-11 08:20:24 -03:00
commit ccfa358f97
206 changed files with 23952 additions and 0 deletions
+75
View File
@@ -0,0 +1,75 @@
/**
* Erros padronizados do SDK CyberCore Signer alinhados ao protocolo CCSP/1.
*/
export class CyberCoreError extends Error {
constructor(message, code = 'COMMUNICATION_ERROR', originalError = null) {
super(message);
this.name = 'CyberCoreError';
this.code = code;
this.originalError = originalError;
}
}
export class AgentNotRunningError extends CyberCoreError {
constructor(message = 'O agente desktop CyberCore Signer não está em execução ou não foi detectado em 127.0.0.1:21325.') {
super(message, 'AGENT_NOT_RUNNING');
this.name = 'AgentNotRunningError';
}
}
export class ApplicationNotAuthorizedError extends CyberCoreError {
constructor(message = 'A aplicação web não foi autorizada pelo usuário no CyberCore Signer.') {
super(message, 'APPLICATION_NOT_AUTHORIZED');
this.name = 'ApplicationNotAuthorizedError';
}
}
export class UserCancelledError extends CyberCoreError {
constructor(message = 'A operação de assinatura foi cancelada pelo usuário.') {
super(message, 'USER_CANCELLED');
this.name = 'UserCancelledError';
}
}
export class CertificateNotFoundError extends CyberCoreError {
constructor(message = 'Certificado digital não encontrado ou não selecionado.') {
super(message, 'CERTIFICATE_NOT_FOUND');
this.name = 'CertificateNotFoundError';
}
}
export class PinRequiredError extends CyberCoreError {
constructor(message = 'PIN do token ou smart card A3 é obrigatório.') {
super(message, 'PIN_REQUIRED');
this.name = 'PinRequiredError';
}
}
export class PinIncorrectError extends CyberCoreError {
constructor(message = 'O PIN informado para o dispositivo A3 está incorreto.') {
super(message, 'PIN_INCORRECT');
this.name = 'PinIncorrectError';
}
}
export class TokenNotFoundError extends CyberCoreError {
constructor(message = 'Token USB ou smart card não foi detectado no computador.') {
super(message, 'TOKEN_NOT_FOUND');
this.name = 'TokenNotFoundError';
}
}
export class RequestExpiredError extends CyberCoreError {
constructor(message = 'A solicitação de assinatura expirou.') {
super(message, 'REQUEST_EXPIRED');
this.name = 'RequestExpiredError';
}
}
export class ReplayDetectedError extends CyberCoreError {
constructor(message = 'Requisição duplicada detectada (ataque de replay prevenido).') {
super(message, 'REPLAY_DETECTED');
this.name = 'ReplayDetectedError';
}
}
+497
View File
@@ -0,0 +1,497 @@
/**
* 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<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.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<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 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<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'
});
}
/**
* 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
// =========================================================================
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 <script>
if (typeof window !== 'undefined') {
window.CyberCoreSigner = CyberCoreSigner;
}
export default CyberCoreSigner;