Isox

CIHex.pmDocumentationLicense

Codec ISO 20022 para o catálogo de mensagens do SPI (Pix, Banco Central do Brasil), com encode/2 e decode/1 genéricos por envelope (cabeçalho mais mensagem) e um módulo separado de assinatura XMLDSig no perfil do Manual de Segurança do SFN.

A lib cuida só do formato das mensagens. Ela não conhece transporte nem regra de negócio de quem a usa.

Instalação

def deps do
[
{:isox, "~> 0.1.0"}
]
end

Uso

Toda mensagem do catálogo viaja com um cabeçalho comum (Isox.AppHdr). Os dois juntos formam um Isox.Envelope, que é a entrada de Isox.encode/2 e a saída de Isox.decode/1.

# Truncar pra milissegundo importa: é a precisão que o XML carrega de volta.
# Sem truncar, o valor devolvido por decode/1 não é `==` ao original.
header = %Isox.AppHdr{
from_ispb: "11111111",
to_ispb: "22222222",
biz_msg_idr: "M123456780123456789abcdefghijklm",
created_at: DateTime.utc_now() |> DateTime.truncate(:millisecond)
}
mensagem = %Isox.Pacs008{
msg_id: "M123456780123456789abcdefghijklm",
created_at: DateTime.utc_now() |> DateTime.truncate(:millisecond),
svc_lvl_prtry: "PAGPRI",
end_to_end_id: "E11111111202609121030abcdefghijk",
value: "150.00",
accptnc_dt_tm: DateTime.utc_now() |> DateTime.truncate(:millisecond),
lcl_instrm: "MANU",
dbtr_name: "Fulano de Tal",
dbtr_cpf_cnpj: "12345678901",
dbtr_acct_id: "00012345",
dbtr_agt_ispb: "11111111",
cdtr_cpf_cnpj: "12345678000199",
cdtr_acct_id: "00098765",
cdtr_agt_ispb: "22222222",
purp_cd: "IPAY"
}
envelope = %Isox.Envelope{header: header, message: mensagem}
{:ok, xml} = Isox.encode(envelope, :v1_16)
{:ok, %Isox.Envelope{message: ^mensagem}, :v1_16} = Isox.decode(xml)

Isox.decode/1 descobre o tipo da mensagem e a versão do schema pelo namespace do XML, então você não precisa saber de antemão o que está chegando.

Quando o tipo já é conhecido, cada mensagem tem seu módulo de baixo nível, com a mesma forma de API e sem o despacho automático:

{:ok, xml} = Isox.Pacs008.encode(mensagem, header, :v1_16)
{:ok, ^mensagem, :v1_16} = Isox.Pacs008.decode(xml)

Mensagens cobertas: Admi002, Admi004, Camt014, Camt025, Camt029, Camt052, Camt053, Camt054, Camt055, Camt060, Pacs002, Pacs004, Pacs008, Pain009, Pain011, Pain012, Pain013, Pain014, Pibr001, Pibr002, Reda014, Reda016, Reda017, Reda022, Reda031, Reda041, Trck002.

Lote

Onze mensagens aceitam várias transações num XML só, porque o elemento de transação é max: ilimitado no XSD: Pacs002, Pacs004, Pacs008, Camt052, Camt053, Camt054, Pain009, Pain012, Pain013, Pain014 e Trck002.

encode/3 aceita uma mensagem ou uma lista. decode/1 devolve uma struct ou uma lista, conforme o que o XML trouxer. É a mesma função nos dois casos, sem API paralela:

# enviando um lote de 2 devoluções (pacs.004)
{:ok, xml} = Isox.Pacs004.encode([devolucao1, devolucao2], header, :v1_5)
# recebendo: decode devolve lista quando o XML trouxer mais de 1 item
{:ok, [d1, d2], :v1_5} = Isox.Pacs004.decode(xml)
# 1 mensagem continua funcionando igual, sem lista
{:ok, xml} = Isox.Pacs004.encode(devolucao1, header, :v1_5)
{:ok, ^devolucao1, :v1_5} = Isox.Pacs004.decode(xml)

Campos de GrpHdr como msg_id e created_at são únicos por XML, não por transação. Em lote, encode/3 confere que todos os itens da lista concordam nesses campos e devolve erro se divergirem, em vez de usar o primeiro e ignorar o resto em silêncio.

Assinatura digital

Implementa o perfil do Manual de Segurança do SFN Vol. II §3: canonicalização XML exclusiva, RSA-SHA256 e as três <ds:Reference> (KeyInfo, AppHdr com transformação enveloped-signature e Document sem atributo URI).

# app_hdr_xml e document_xml precisam vir já em forma canônica exclusiva.
# sign/4 não canonicaliza: isso é responsabilidade de quem chama.
signature_xml = Isox.sign(app_hdr_xml, document_xml, minha_chave_privada_der, meu_certificado_der)
:ok = Isox.verify(envelope_recebido_xml, certificado_de_quem_assinou_der)

Para controle mais fino, como montar um perfil com outro número de <ds:Reference>, use Isox.Xmldsig.Signer e Isox.Xmldsig.Verifier direto.

Certificados

Assinar e verificar usam chaves de lados opostos da conversa. Você assina com a sua chave privada e o seu certificado. Você verifica com o certificado público de quem assinou, nunca com o seu.

Na prática, um PSP guarda a própria chave privada para assinar e o certificado público do Bacen para verificar as respostas dele. Quem simula o Bacen faz o espelho disso.

Para testar localmente, gere um par para cada lado:

psp = Isox.generate_test_certificate()
signature_xml = Isox.sign(app_hdr_xml, document_xml, psp.private_key_der, psp.certificate_der)
# quem recebe verifica com o certificado do PSP, não com o próprio
:ok = Isox.verify(envelope_xml, psp.certificate_der)

Isox.generate_test_certificate/0 gera tudo em memória e serve só para teste.

Em produção, chave privada não entra em código nem em variável de ambiente em texto puro. O padrão comum é guardar caminhos de arquivo .pem em config e decodificar para DER na sua aplicação. Esta lib de propósito não lê arquivo, env nem config: ela só recebe bytes.

Para medir o throughput local de assinatura e verificação:

MIX_ENV=test mix xmldsig.spike

Gerando o codec a partir dos XSDs

Os XSDs publicados pelo Banco Central não são redistribuídos aqui. Para gerar ou regenerar o codec a partir deles:

mix catalog.gen --xsd-dir /caminho/para/xsd --out lib/isox/generated

Desenvolvimento

mix deps.get
mix precommit # compila com warning como erro, formata, credo --strict, testes
mix dialyzer # análise estática, mais lenta, fora do precommit
mix docs # gera a documentação em doc/

Os testes de round-trip contra os exemplos oficiais do catálogo precisam da variável CATALOGO_SPI_DIR apontando para um diretório local com os XSDs e exemplos. Sem ela, essa suíte é pulada.

Licença

MIT.