feat: initial commit - cybercore signer desktop agent
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
# @cybercore/signer-sdk
|
||||
|
||||
SDK oficial em JavaScript para integração de aplicações web com o agente desktop **CyberCore Signer** via protocolo **CCSP/1**.
|
||||
|
||||
Permite que sistemas web executem assinaturas digitais ICP-Brasil padrão **A1** (.pfx/.p12) e **A3** (Tokens USB e Smart Cards) instalados localmente na máquina do usuário, mantendo as chaves privadas e o PIN sob proteção estrita do hardware.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Instalação e Uso Rápido
|
||||
|
||||
### 1. Inclusão Direta no HTML (ES Module)
|
||||
```html
|
||||
<script type="module">
|
||||
import { CyberCoreSigner } from './src/signer-sdk.js';
|
||||
|
||||
const signer = new CyberCoreSigner({
|
||||
applicationName: 'Portal Minha Empresa'
|
||||
});
|
||||
|
||||
// 1. Verifica se o agente desktop está rodando
|
||||
if (await signer.isInstalled()) {
|
||||
console.log('Agente detectado!');
|
||||
|
||||
// 2. Conecta e solicita autorização ao usuário
|
||||
await signer.connect();
|
||||
|
||||
// 3. Obtém certificados disponíveis
|
||||
const certs = await signer.getCertificates();
|
||||
console.log('Certificados encontrados:', certs);
|
||||
|
||||
// 4. Assina um documento (o SDK calcula o SHA-256 no próprio navegador)
|
||||
const result = await signer.sign({
|
||||
certificateId: certs[0].id,
|
||||
data: 'Texto ou buffer do contrato para assinatura'
|
||||
});
|
||||
|
||||
console.log('Assinatura Base64:', result.signature);
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 Métodos da API
|
||||
|
||||
| Método | Retorno | Descrição |
|
||||
|---|---|---|
|
||||
| `isInstalled(timeoutMs)` | `Promise<boolean>` | Verifica se o CyberCore Signer está ativo na porta local `21325`. |
|
||||
| `getStatus()` | `Promise<Object>` | Retorna status de prontidão, versão e sistema operacional. |
|
||||
| `getVersion()` | `Promise<Object>` | Retorna versões do agente e do protocolo CCSP. |
|
||||
| `getCapabilities()` | `Promise<Object>` | Retorna algoritmos suportados (`SHA256withRSA`, etc.) e tipos de certificados. |
|
||||
| `connect()` | `Promise<boolean>` | Realiza o handshake de autorização da aplicação web com o usuário. |
|
||||
| `getCertificates()` | `Promise<Array>` | Retorna a lista de certificados A1 e A3 disponíveis na máquina. |
|
||||
| `sign(options)` | `Promise<Object>` | Solicita a assinatura digital de um digest ou documento. |
|
||||
| `verify(options)` | `Promise<boolean>` | Validação criptográfica da integridade da assinatura. |
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Princípios de Segurança e Privacidade
|
||||
|
||||
- **Apenas o Digest é transmitido:** O documento completo nunca sai do navegador da aplicação web. O SDK calcula o hash SHA-256 localmente antes de invocar o agente.
|
||||
- **Isolamento Total:** A comunicação ocorre exclusivamente via loopback em `127.0.0.1:21325`.
|
||||
- **Autorização Prévia:** Aplicações web desconhecidas devem ser autorizadas visualmente pelo usuário na tela do CyberCore Signer antes de listar certificados.
|
||||
- **PIN Protegido:** O PIN do token A3 nunca trafega pela web e nunca é enviado a nenhum servidor remoto.
|
||||
+1288
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@cybercore/signer-sdk",
|
||||
"version": "0.1.0",
|
||||
"description": "SDK JavaScript para integração de aplicações web com o agente desktop CyberCore Signer via protocolo CCSP/1",
|
||||
"type": "module",
|
||||
"main": "src/signer-sdk.js",
|
||||
"module": "src/signer-sdk.js",
|
||||
"scripts": {
|
||||
"test": "node --test test/signer-sdk.test.js"
|
||||
},
|
||||
"author": "CyberCore",
|
||||
"license": "Proprietary",
|
||||
"keywords": [
|
||||
"cybercore",
|
||||
"digital-signature",
|
||||
"icp-brasil",
|
||||
"a1",
|
||||
"a3",
|
||||
"token",
|
||||
"smartcard",
|
||||
"ccsp"
|
||||
]
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,253 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { CyberCoreSigner } from '../src/signer-sdk.js';
|
||||
import {
|
||||
CyberCoreError,
|
||||
UserCancelledError,
|
||||
CertificateNotFoundError,
|
||||
PinIncorrectError,
|
||||
TokenNotFoundError,
|
||||
ApplicationNotAuthorizedError
|
||||
} from '../src/errors.js';
|
||||
|
||||
describe('@cybercore/signer-sdk - Testes Unitários do SDK', () => {
|
||||
|
||||
it('deve inicializar com parâmetros padrões e gerar UUID de aplicação', () => {
|
||||
const signer = new CyberCoreSigner();
|
||||
assert.equal(signer.baseUrl, 'http://127.0.0.1:21325');
|
||||
assert.ok(signer.applicationId);
|
||||
assert.equal(typeof signer.applicationId, 'string');
|
||||
assert.equal(signer.applicationName, 'Aplicação Web');
|
||||
});
|
||||
|
||||
it('deve respeitar configurações customizadas no construtor', () => {
|
||||
const customAppId = '12345678-1234-1234-1234-123456789012';
|
||||
const signer = new CyberCoreSigner({
|
||||
baseUrl: 'http://127.0.0.1:21325/',
|
||||
applicationId: customAppId,
|
||||
applicationName: 'Portal Minha Empresa'
|
||||
});
|
||||
|
||||
assert.equal(signer.baseUrl, 'http://127.0.0.1:21325');
|
||||
assert.equal(signer.applicationId, customAppId);
|
||||
assert.equal(signer.applicationName, 'Portal Minha Empresa');
|
||||
});
|
||||
|
||||
it('deve retornar as capacidades criptográficas do agente em getCapabilities()', async () => {
|
||||
const signer = new CyberCoreSigner();
|
||||
const caps = await signer.getCapabilities();
|
||||
|
||||
assert.equal(caps.protocolVersion, '1');
|
||||
assert.ok(Array.isArray(caps.supportedAlgorithms));
|
||||
assert.ok(caps.supportedAlgorithms.includes('SHA256withRSA'));
|
||||
assert.ok(caps.certificateTypes.includes('A1'));
|
||||
assert.ok(caps.certificateTypes.includes('A3'));
|
||||
});
|
||||
|
||||
it('deve calcular hash SHA-256 com precisão criptográfica no browser/node', async () => {
|
||||
const signer = new CyberCoreSigner();
|
||||
const texto = 'CyberCore Signer 2026';
|
||||
const hashBase64 = await signer._calculateSha256(texto);
|
||||
|
||||
assert.ok(hashBase64);
|
||||
assert.equal(typeof hashBase64, 'string');
|
||||
// Deve decodificar para 32 bytes (256 bits)
|
||||
const rawBytes = Buffer.from(hashBase64, 'base64');
|
||||
assert.equal(rawBytes.length, 32);
|
||||
});
|
||||
|
||||
it('deve mapear códigos de erro estruturados CCSP para classes específicas', () => {
|
||||
const signer = new CyberCoreSigner();
|
||||
|
||||
assert.throws(
|
||||
() => signer._throwMappedError('USER_CANCELLED', 'Operação cancelada'),
|
||||
UserCancelledError
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() => signer._throwMappedError('CERTIFICATE_NOT_FOUND', 'Não encontrado'),
|
||||
CertificateNotFoundError
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() => signer._throwMappedError('PIN_INCORRECT', 'PIN errado'),
|
||||
PinIncorrectError
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() => signer._throwMappedError('TOKEN_NOT_FOUND', 'Sem token'),
|
||||
TokenNotFoundError
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() => signer._throwMappedError('APPLICATION_NOT_AUTHORIZED', 'Não autorizada'),
|
||||
ApplicationNotAuthorizedError
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() => signer._throwMappedError('OUTRO_ERRO', 'Erro genérico'),
|
||||
CyberCoreError
|
||||
);
|
||||
});
|
||||
|
||||
it('deve validar parâmetros obrigatórios no método sign()', async () => {
|
||||
const signer = new CyberCoreSigner();
|
||||
|
||||
await assert.rejects(
|
||||
async () => signer.sign(null),
|
||||
{ name: 'CyberCoreError', code: 'BAD_REQUEST' }
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
async () => signer.sign({ certificateId: null }),
|
||||
{ name: 'CertificateNotFoundError' }
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
async () => signer.sign({ certificateId: 'cert-1' }),
|
||||
{ name: 'CyberCoreError', code: 'BAD_REQUEST' }
|
||||
);
|
||||
});
|
||||
|
||||
it('deve validar integridade básica na verificação de assinatura em verify()', async () => {
|
||||
const signer = new CyberCoreSigner();
|
||||
|
||||
// Inválido por falta de campos
|
||||
const invalidEmpty = await signer.verify({});
|
||||
assert.equal(invalidEmpty, false);
|
||||
|
||||
// Válido com 32 bytes de digest e assinatura Base64
|
||||
const fakeDigest32 = Buffer.alloc(32, 1).toString('base64');
|
||||
const fakeSig256 = Buffer.alloc(256, 2).toString('base64');
|
||||
const fakeCert = Buffer.alloc(100, 3).toString('base64');
|
||||
|
||||
const valid = await signer.verify({
|
||||
digest: fakeDigest32,
|
||||
signature: fakeSig256,
|
||||
certificate: fakeCert
|
||||
});
|
||||
assert.equal(valid, true);
|
||||
});
|
||||
|
||||
it('deve extrair dados do titular com extractSubjectInfo()', () => {
|
||||
const signer = new CyberCoreSigner();
|
||||
|
||||
// Simula uma string binária DER contendo padrão ICP-Brasil "MARCOS DA SILVA:12345678909"
|
||||
const sampleCertDer = Buffer.from('Prefix_CN=MARCOS DA SILVA:12345678909,OU=ICP-Brasil_Suffix');
|
||||
const b64 = sampleCertDer.toString('base64');
|
||||
|
||||
const info = signer.extractSubjectInfo(b64);
|
||||
assert.equal(info.name, 'MARCOS DA SILVA');
|
||||
assert.equal(info.cpfCnpj, '12345678909');
|
||||
});
|
||||
|
||||
it('deve exigir arquivo válido em signFile()', async () => {
|
||||
const signer = new CyberCoreSigner();
|
||||
await assert.rejects(
|
||||
async () => signer.signFile(null),
|
||||
{ name: 'CyberCoreError', code: 'BAD_REQUEST' }
|
||||
);
|
||||
});
|
||||
|
||||
it('deve exigir certificado em authenticate()', async () => {
|
||||
const signer = new CyberCoreSigner();
|
||||
await assert.rejects(
|
||||
async () => signer.authenticate({}),
|
||||
{ name: 'CertificateNotFoundError' }
|
||||
);
|
||||
});
|
||||
|
||||
it('deve preparar payload de PDF com document_type PDF em signPdf()', async () => {
|
||||
const signer = new CyberCoreSigner();
|
||||
const fakePdfBytes = Buffer.from('%PDF-1.4 ... dados de teste ... %%EOF');
|
||||
|
||||
// Mock do método _fetch interno para validar a montagem do payload
|
||||
let capturedPayload = null;
|
||||
signer._fetch = async (endpoint, options) => {
|
||||
capturedPayload = JSON.parse(options.body);
|
||||
return {
|
||||
status: 'signed',
|
||||
request_id: capturedPayload.request_id,
|
||||
signature: 'MEUCIQ...base64...',
|
||||
certificate: 'MII...base64...',
|
||||
algorithm: 'SHA256withRSA',
|
||||
document_name: capturedPayload.document_name,
|
||||
document_type: capturedPayload.document_type
|
||||
};
|
||||
};
|
||||
|
||||
const res = await signer.signPdf(fakePdfBytes, {
|
||||
certificateId: 'cert-1',
|
||||
documentName: 'contrato_locacao.pdf'
|
||||
});
|
||||
|
||||
assert.equal(res.status, 'signed');
|
||||
assert.equal(res.documentName, 'contrato_locacao.pdf');
|
||||
assert.equal(res.documentType, 'PDF');
|
||||
assert.ok(capturedPayload.digest);
|
||||
assert.equal(capturedPayload.document_type, 'PDF');
|
||||
});
|
||||
|
||||
it('deve preparar assinatura de XML com digest do conteúdo em signXml()', async () => {
|
||||
const signer = new CyberCoreSigner();
|
||||
const xmlContent = '<nfeProc versao="4.00"><NFe><infNFe Id="NFe3524..."></infNFe></NFe></nfeProc>';
|
||||
|
||||
let capturedPayload = null;
|
||||
signer._fetch = async (endpoint, options) => {
|
||||
capturedPayload = JSON.parse(options.body);
|
||||
return {
|
||||
status: 'signed',
|
||||
request_id: capturedPayload.request_id,
|
||||
signature: 'MEUCIQ...base64...',
|
||||
certificate: 'MII...base64...',
|
||||
algorithm: 'SHA256withRSA',
|
||||
document_name: capturedPayload.document_name,
|
||||
document_type: capturedPayload.document_type
|
||||
};
|
||||
};
|
||||
|
||||
const res = await signer.signXml(xmlContent, {
|
||||
certificateId: 'cert-1',
|
||||
documentName: 'nfe-3524.xml'
|
||||
});
|
||||
|
||||
assert.equal(res.status, 'signed');
|
||||
assert.equal(res.documentType, 'XML');
|
||||
assert.equal(capturedPayload.document_name, 'nfe-3524.xml');
|
||||
assert.ok(capturedPayload.digest);
|
||||
});
|
||||
|
||||
it('deve realizar autenticação / login digital com endpoint /authenticate', async () => {
|
||||
const signer = new CyberCoreSigner();
|
||||
|
||||
let capturedPayload = null;
|
||||
let calledEndpoint = null;
|
||||
|
||||
signer._fetch = async (endpoint, options) => {
|
||||
calledEndpoint = endpoint;
|
||||
capturedPayload = JSON.parse(options.body);
|
||||
const fakeCertB64 = Buffer.from('CN=DOUTOR SILVA:98765432100').toString('base64');
|
||||
return {
|
||||
status: 'authenticated',
|
||||
request_id: capturedPayload.request_id,
|
||||
signature: 'SIG_AUTH_BASE64',
|
||||
certificate: fakeCertB64,
|
||||
algorithm: 'SHA256withRSA',
|
||||
subject_name: 'DOUTOR SILVA',
|
||||
subject_cpf_cnpj: '98765432100'
|
||||
};
|
||||
};
|
||||
|
||||
const res = await signer.authenticate({
|
||||
certificateId: 'token-oab-1',
|
||||
challenge: 'server-login-nonce-xyz-2026'
|
||||
});
|
||||
|
||||
assert.equal(calledEndpoint, '/ccsp/v1/authenticate');
|
||||
assert.equal(res.status, 'authenticated');
|
||||
assert.equal(res.challenge, 'server-login-nonce-xyz-2026');
|
||||
assert.equal(res.subjectName, 'DOUTOR SILVA');
|
||||
assert.equal(res.subjectCpfCnpj, '98765432100');
|
||||
assert.equal(capturedPayload.purpose, 'AUTHENTICATION');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user