Escrever um provedor SPP v1 do zero¶
Para: Dev · Adm — Conferido em 2026-11-14.
O que é¶
Este guia leva você, passo a passo, de "tenho um parceiro de pagamento" até "tenho um provedor SPP v1 registrado e habilitado". Um provedor é um processo à parte (dracma_psp_<nome>) que traduz o protocolo SPP para a API do parceiro. O executor é o único chamador; o provedor não guarda segredo de tenant, não conhece ledger e não acessa banco.
O código de referência é o provedor dracma_psp_exemplo, no repositório do kit de conformidade (dracma_spp_kit/examples/dracma_psp_exemplo, veja a seção "Código de referência" no fim). Ele faz cash_in PIX (create_charge, get_charge, callbacks/verify, health, descriptor), verifica o HMAC v2 do executor, incluindo replay de nonce, e passa a suíte completa do kit. O parceiro dele é uma API HTTP fictícia, que nos testes é o fake do kit e roda sem rede externa. Substituir essa parte pela chamada ao seu parceiro é o trabalho real do passo 5.
Quando usar¶
- Você vai plugar um parceiro novo (PSP, banco, exchange) sem mexer no núcleo.
- Você mantém um provedor existente e quer conferir se ele cumpre o contrato.
- Você é de infraestrutura e precisa saber o que o ops instala e o que o provedor precisa fornecer.
Pré-requisito: leia o resumo do protocolo em protocolo.md. Este guia não o repete; ele diz o que fazer em cada ponto.
Como funciona¶
Os doze passos abaixo seguem a ordem em que você trabalha. Os passos 3 a 9 são código; 10 é teste; 11 e 12 são operação.
Passo 0: decida a linguagem¶
O backend de um provedor novo nasce em uma linguagem do rol sancionado: Go, Rust, Elixir, C#, Zig ou Ruby. Node e PHP estão em saída e não recebem serviço novo. A escolha sai do guia de fit e vira uma ADR curta, não é gosto.
| Pergunta de fit | Se a resposta for sim, pese a favor de |
|---|---|
| O parceiro tem SDK oficial só em uma linguagem? | Essa linguagem, se estiver no rol |
| Você quer zerar segredo em memória por construção e tipos fortes na fronteira? | Rust |
| Você quer um binário estático, biblioteca padrão suficiente e deploy simples? | Go |
| O provedor mantém muitas conexões longas ou processa fila própria? | Elixir |
| A equipe já opera .NET e o parceiro entrega SDK .NET? | C# |
| É uma cola pequena sobre um SDK Ruby existente? | Ruby |
| É um tradutor de formato de baixo nível, sem dependências? | Zig |
Registre a ADR com o parceiro, a linguagem, as alternativas descartadas e o motivo. O piso de engenharia é o mesmo em qualquer linguagem: segredo nunca em log, SQL parametrizado (se houver banco), erro nunca engolido, teste nunca silenciado, arquivos até 750 linhas e toda função comentada com "o quê" e "onde".
Passo 1: levante o que o parceiro oferece¶
Antes de escrever código, preencha esta tabela com a documentação do parceiro. Cada linha vira uma capacidade do descriptor; o que o parceiro não oferece fica false e a rota responde 501 unsupported.
| O parceiro oferece... | Capacidade no descriptor | Rota SPP | Se não oferece |
|---|---|---|---|
| Criar cobrança PIX | directions contém cash_in |
POST /charges |
Não é provedor de cash_in |
| Consultar cobrança por id | charge_lookup |
POST /charges/get |
false e 501 |
| Achar cobrança pela referência dele | charge_ref_resolution |
POST /charges/resolve-ref |
false e 501 |
| Criar pagamento PIX (saque) | directions contém cash_out |
POST /payouts |
501 |
| Consultar saque por chave | directions contém cash_out |
POST /payouts/get |
501 |
| Webhook assinado | callbacks |
POST /callbacks/verify |
callbacks: false e 501 |
| Consulta de chave DICT | key_lookup.dict |
POST /keys/lookup |
501 |
| Saldo da conta | balance |
POST /account/balance |
501 |
| Teste de credencial sem efeito | credential_check |
POST /credentials/check |
501 |
| Cancelar cobrança | charge_cancel |
POST /charges/cancel |
501 |
Anote também, porque o descriptor exige: ambientes do parceiro (existe sandbox?), hosts de saída, formato da credencial (quais campos são segredo), como o webhook é assinado (cabeçalho, algoritmo, o que a assinatura cobre), IPs de origem do callback, limites de taxa e se o parceiro deduplica por identificador. Se o parceiro não tem sandbox, o provedor só opera em produção, e fora de produção só com a exceção auditada do ops.
Regra de ouro: capacidade true significa que a operação funciona e é coberta pela certificação. Não declare o que você não implementou.
Passo 2: escreva o descriptor¶
O descriptor é o JSON que o provedor serve em GET /spp/v1/descriptor. É a única fonte de capacidade, schema de credencial, URLs, política de ambiente, egress e assinatura de callback. Painel, API e ops derivam dele. Veja os campos em descriptor.md. O exemplo de referência traz um descriptor completo e válido (src/descriptor.rs); copie e ajuste:
name:^[a-z0-9_-]{1,64}$, imutável. Vira o<provedor>da rota de callback, da métrica e do nome do segredo HMAC.synthetic:truesó para dinheiro de mentira. Provedor sintético é recusado em produção.credential_schema: JSON Schema comadditionalProperties: false; marque cada segredo com"x-secret": truee dêx-labelem português para o painel desenhar o formulário.environments:sandboxsó se o parceiro tiver;liveé obrigatório para provedor não sintético.egress_hosts: listahost:portade tudo que o provedor chama. O ops libera só isso na rede.idempotency.retention_hours: no mínimo 24.
Mudou o descriptor, o registro compara o hash; mudança incompatível (capacidade removida, schema mais restrito, egress alterado) volta o provedor ao estado registered.
Passo 3: o esqueleto do servidor loopback¶
Requisitos que o esqueleto precisa cumprir antes de qualquer operação:
- Escutar só em
127.0.0.1:<porta>; nunca em todas as interfaces. O endereço vem do ops (no exemplo,EXEMPLO_BIND, com padrão127.0.0.1:7101e recusa de qualquer IP fora de loopback). - Ler o segredo do par HMAC semeado pelo ops: o segredo do par se chama
INTERNAL_HMAC_<PROVEDOR>_EXECUTOR(nome do provedor em maiúsculas,-vira_) e o provedor o recebe por variável de ambiente ou, como no exemplo (EXEMPLO_HMAC_SECRET_FILE), por caminho de arquivo, que mantém o valor fora do ambiente do processo. Ausente ou curto: falhe o boot com log claro. - Autenticar toda rota, inclusive
descriptorehealth, antes do handler (deny-default). - Limitar o corpo a 1 MiB e aceitar JSON estrito (
additionalProperties: false): campo desconhecido no pedido é erro. - Responder
Spp-Version: 1.0e erros emapplication/problem+json. - Subir sem depender do executor nem de qualquer outro serviço (independência de runtime). Se o parceiro estiver fora, o provedor sobe mesmo assim e reporta
degradedoudownnohealth.
No exemplo, a autenticação (hmac_v2.rs) cobre o item 3, e o roteador só registra as rotas da v1 mínima; as demais respondem 501 unsupported.
Passo 4: verifique o HMAC v2 do executor¶
O executor assina cada chamada. Os cabeçalhos são Dracma-Caller, Dracma-Internal-Timestamp, Dracma-Internal-Nonce e Dracma-Internal-Signature: v2=<hex>. O texto assinado (canônico) é:
v2\n<timestamp>\n<nonce>\n<METODO>\n<path?query>\n<sha256 hex do corpo>
O path é o completo, como enviado (/spp/v1/charges), sem \n no final. A assinatura é HMAC-SHA256 do canônico com o segredo do par, em hexadecimal minúsculo. Como a credencial vai no corpo, o hash do corpo no canônico a protege contra adulteração.
Aplique as recusas nesta ordem; a primeira que falha decide:
| Ordem | Condição | HTTP | code |
|---|---|---|---|
| 1 | Cabeçalho de assinatura ausente ou vazio | 401 | missing_signature |
| 2 | Dracma-Caller ausente ou fora do conjunto de chamadores conhecidos |
401 | unknown_caller |
| 3 | Assinatura sem o prefixo v2= |
401 | unsupported_signature_version |
| 4 | Timestamp fora do formato ou a mais de 300 s do relógio | 401 | timestamp_out_of_window |
| 5 | Nonce fora de 32 hexadecimais minúsculos | 401 | invalid_nonce |
| 6 | HMAC não confere (comparação em tempo constante) | 401 | invalid_signature |
| 7 | Nonce já visto nos últimos 600 s | 401 | replayed_nonce |
| 8 | Chamador autenticado, mas diferente de executor |
403 | caller_not_allowed |
Essa é a ordem do contrato (SPP v1, S2 e a tabela P051): unknown_caller (401) vem logo depois da assinatura ausente e caller_not_allowed (403) é o último, só para quem passou por todas as verificações de autenticidade. O kit de conformidade (C-AUTH-09) aceita 403 caller_not_allowed ou 401 unknown_caller para um chamador que não é o executor, mas siga a ordem acima.
Detalhes que costumam quebrar:
- O timestamp deve casar
^(0|[1-9][0-9]{0,18})$antes de qualquer conversão: só dígitos ASCII, sem sinal, sem espaço, sem zero à esquerda, sem notação científica. Fora disso,timestamp_out_of_window. - Grave o nonce só depois de o HMAC conferir, para uma requisição forjada não queimar nonce alheio.
- O cache de nonces pode ficar em memória. O reinício apaga o cache, e a janela de 300 s limita o dano.
- O provedor só conhece o segredo do par com o executor. Um
Dracma-Callerfora do conjunto conhecido é recusado cedo, no passo 2, comounknown_caller(401). Um chamador conhecido que não é oexecutorsó chega ao passo 8 depois de a assinatura e o nonce conferirem, e recebe403 caller_not_allowed; isso atende a regra de que só o executor chama o provedor, sem criar oráculo de quem existe. - Compare sempre em tempo constante (
ct_eqem Rust,hmac.Equalem Go).
O vetor público de teste do contrato dos internos vale para o algoritmo em si, qualquer que seja o path. O exemplo reproduz o segundo vetor (chamador executor) byte a byte: segredo teste-publico-interno-v2-nao-usar-em-producao-0123456789, canônico completo e assinatura v2=d5cc715a459ee3dabd3b812013b3d4904f639e2f5fe2944be2ed4b75a4132909. Se o seu teste reproduz o canônico e a assinatura, sua implementação do HMAC está correta.
Passo 5: implemente cada operação¶
Implemente nesta ordem; cada uma tem um teste antes de a próxima começar.
| Operação | Rota | Regra principal | Coberta no exemplo |
|---|---|---|---|
| descriptor | GET /descriptor |
JSON do passo 2, autenticado | sim |
| health | GET /health |
Sem credencial de tenant e sem efeito no parceiro; ok, degraded ou down |
sim |
| create_charge | POST /charges |
Idempotente por Spp-Idempotency-Key (igual ao deposit_id); não move dinheiro |
sim |
| get_charge | POST /charges/get |
Fetch-back: devolve o fato real; status desconhecido vira unrecognized |
sim |
| verify_callback | POST /callbacks/verify |
Confere a assinatura sobre o corpo cru em base64; devolve eventos normalizados | sim |
| resolve_charge_ref | POST /charges/resolve-ref |
deposit_id: null quando o pagamento não é nosso (200, sem erro) |
501 no exemplo |
| create_payout | POST /payouts |
Pode mover dinheiro; veja os passos 6 e 7 | 501 no exemplo |
| get_payout | POST /payouts/get |
Fonte de verdade para resolver ambiguidade; sempre devolve fee |
501 no exemplo |
| dict_lookup, balance, credential_check, charge_cancel | rotas do passo 1 | Só se a capacidade for true |
501 no exemplo |
Pontos de contrato que o código precisa respeitar:
- Dinheiro é string inteira na menor unidade (
"1050"é R$ 10,50 emBRL). create_chargedevolvepsp_ref; se o parceiro não devolve id, devolva o própriodeposit_id. Primeira execução pode ser201, repetição é200comSpp-Replayed: true.get_chargesó afirma o que o parceiro confirma. O executor relata e a API decide o que é pago.- Em
verify_callback, verifique sobre os bytes debody_base64, sem reserializar o JSON. Assinatura ausente, inválida ou corpo malformado:422 callback_unauthenticated, sem dizer qual dos três (não vire oráculo). O callback só afirma (claimed); o executor confere por fetch-back antes de qualquer efeito. - Quando você trata o parceiro por HTTP, limite a chamada a
Spp-Timeout-Msmenos 500 ms e nunca repassetraceparentao parceiro. - Pedido de pix chega assim (resumo do que o exemplo aceita; a credencial segue o
credential_schemado descriptor, aquiapi_keyewebhook_secret):
{
"method": "pix",
"context": { "connection_id": "0192a000-0000-7000-8000-000000000001", "operation_id": "0192a000-0000-7000-8000-000000000002", "mode": "sandbox", "runtime_env": "dev" },
"credential": { "api_key": "sk_test_exemplo_0001", "webhook_secret": "whsec_test_exemplo" },
"deposit_id": "dep-0001", "amount": "1050", "currency": "BRL",
"payer": { "name": "Fulano de Teste", "document": "12345678901", "email": null, "phone": null },
"callback_url": "https://cb.example/callbacks/exemplo/0192a000-0000-7000-8000-000000000001",
"expires_at": "2026-11-01T12:00:00Z"
}
E a resposta da primeira execução (201; o psp_ref é o id que o parceiro devolveu):
{
"psp_ref": "ch_0001",
"expires_at": null,
"pix": { "copy_paste": "000201EXEMPLO ch_0001", "qr_code_png_base64": null }
}
Um provedor com saque ainda precisa de create_payout e get_payout. Aplique ali as invariantes do passo 6 com mais rigor: saque nunca é reenviado às cegas, e a tarifa ilegível vira fee: {"state":"unrecognized"}, nunca erro de envio.
Passo 6: mapeie os erros do parceiro para code e effect¶
Toda falha do provedor é um problem+json com code e effect. O effect diz ao executor se algo pode ter acontecido no parceiro: none é garantia de que nada ocorreu, unknown é ambíguo. Na dúvida, unknown. Esta é a tabela de decisão para a resposta do parceiro:
| O que aconteceu com a chamada ao parceiro | code |
HTTP | effect |
O executor trata como |
|---|---|---|---|---|
| Estourou o prazo, ou a conexão caiu depois de enviar o corpo | upstream_timeout |
504 | unknown |
Timeout; conta no breaker; failover de depósito |
| O parceiro respondeu 5xx, ou respondeu algo ilegível depois do envio | upstream_unavailable |
502 ou 503 | unknown |
Unavailable; conta no breaker; failover de depósito |
| O parceiro respondeu 4xx (recusa de negócio, credencial recusada, valor inválido) | upstream_rejected |
422 | none |
Rejected; não conta no breaker; sem failover |
| O parceiro respondeu 2xx com corpo irreconhecível | unrecognized_response |
502 | unknown |
Unrecognized; nunca move dinheiro |
| Credencial ou config fora do schema, URL base fora do egress | misconfigured |
422 | none |
Misconfigured; indique o field |
| Modo ou ambiente proibido pela guarda do provedor | environment_forbidden |
403 | none |
Misconfigured |
| Operação que o provedor não tem | unsupported |
501 | none |
Unsupported |
Fila própria cheia (envie Retry-After) |
provider_busy |
429 | none |
Nada saiu; failover de depósito |
| Mesma chave de idempotência com corpo diferente | idempotency_conflict |
409 | none |
Bug do chamador |
| Bug do próprio provedor | internal_error |
500 | unknown |
Unavailable(500); ambíguo |
Invariantes que o provedor não pode quebrar:
- Nunca transforme falha ambígua em definitiva (timeout ou 5xx do parceiro é sempre
unknown) e nunca transforme recusa definitiva em ambígua. create_payoutcom falha de rede depois do envio devolveupstream_timeout. O executor marca o saque comounknowne consulta porpayouts/get; ele não reenvia.- Status ou campo que você não reconhece vira
unrecognized. Nuncasettled, nunca saldo zero. - O
detaildo erro não leva corpo do parceiro, credencial, documento, nome nem chave PIX. - Leia a tarifa com tolerância: falha ao ler
feenão é erro de envio.
Há falhas que o provedor não declara: se ele ficar fora, o executor classifica sozinho (conexão recusada antes de enviar nada é "não saiu"; sem resposta no prazo é ambíguo). Por isso o provedor deve falhar rápido e honesto, não travar.
Passo 7: idempotência¶
Spp-Idempotency-Key é obrigatório em POST /charges (igual ao deposit_id) e em POST /payouts (igual ao psp_idempotency_key). O cabeçalho e o campo do corpo precisam coincidir; senão 400 invalid_request.
Escolha uma de duas formas válidas e declare em idempotency.upstream_dedupe:
- Repassar a chave como identificador de idempotência do parceiro, quando ele deduplica (
upstream_dedupe: true). - Guardar um repositório local de chave para resultado, com retenção de no mínimo 24 horas e sem segredo nem PII além do necessário.
Mesma chave com o mesmo corpo devolve o mesmo resultado com Spp-Replayed: true, sem novo efeito no parceiro. Para comparar o corpo, calcule o hash do pedido sem credential e sem context.operation_id. Mesma chave com corpo diferente é 409 idempotency_conflict. Duas chamadas concorrentes com a mesma chave: uma executa e a outra espera ou recebe 409 idempotency_in_progress com effect: unknown.
O repositório do exemplo (idempotency.rs) é um mapa em memória, suficiente para cobrança em desenvolvimento. Em produção, um reinício apagaria a memória; para saque use upstream_dedupe ou um repositório durável. Além disso, o provedor precisa responder payouts/get para qualquer chave já aceita.
Passo 8: a credencial vem por chamada e não é persistida¶
O executor decifra a credencial do tenant e a envia no corpo de cada chamada. O provedor a usa e a descarta.
- Nunca grave a credencial em disco, log, trace, métrica, mensagem de erro nem a devolva em resposta.
- Nunca a coloque em URL, query ou cabeçalho.
- Redija a credencial no
DebugoutoString(no exemplo a credencial nem derivaDebug, então não há como imprimi-la por engano) e zere buffers quando a linguagem permitir. - Cache permitido: token derivado de curta duração (por exemplo OAuth), em memória, com chave
(connection_id, sha256(credential))e prazo de no máximo 10 minutos. Credencial mudou, o hash mudou e o cache não é usado. - Campo desconhecido na credencial:
422 misconfiguredcomfield. O exemplo faz isso com decodificação estrita (deny_unknown_fields). - O único segredo permanente do provedor é o par HMAC com o executor.
- O segredo do webhook faz parte da credencial:
verify_callbacktambém a recebe no corpo.
Passo 9: logs, traces e métricas¶
- Logs em JSON com
trace_id,connection_id,provider,opeoutcome. Nunca credencial, corpo do parceiro, documento, nome ou chave PIX. O exemplo imprimeprovider,op,outcomeetrace_id. - O
trace_idvem do cabeçalhotraceparentdo executor (segundo campo). Continue o trace (span servidor) e crieupstream.<op>(span cliente) para o parceiro. Nunca repasse otraceparentao parceiro. - Métricas do provedor, com
service.name = dracma_psp_<nome>:spp_upstream_requests_total{op,outcome},spp_upstream_duration_seconds{op},spp_idempotent_replays_total{op}espp_callbacks_verified_total{outcome}. Sem rótulo de tenant ou deconnection_id, por causa da cardinalidade. - O exemplo mínimo não inclui OpenTelemetry nem Prometheus, para ficar pequeno. Um provedor de verdade inclui os dois, com dashboards e alertas versionados no Helm chart.
Passo 10: teste localmente¶
Em ordem:
- Testes do próprio provedor, como os de
dracma_psp_exemplo(src/hmac_v2.rsetests/): o vetor do HMAC byte a byte, replay de nonce, adulteração, janela de tempo, timestamps malformados, rota sem assinatura, idempotência com conflito, guarda de ambiente e callback autêntico contra adulterado. Rodam sem rede. - Matriz de acesso no CI: cada rota do OpenAPI contra anônimo, chamador errado, assinatura adulterada, replay e janela. O número de casos tem que ser igual ao número de rotas (M == N).
- Harness do fake-PSP e kit de conformidade. O harness sobe um parceiro falso que você programa para responder sucesso, 4xx, 5xx, timeout, resposta ilegível e callbacks assinados, e um chamador que faz o papel do executor: assina com o HMAC v2, injeta o
Spp-Timeout-Mse confere, resposta a resposta, que o seu provedor devolve ocodee oeffectesperados. O kit de conformidade roda as mesmas verificações como suíte de certificação e grava o resultado dodescriptor_sha256. Comandos, casos e critérios de aprovação estão em certificacao.md.
Provedor fora de produção opera só contra sandbox ou fake. Nenhum teste dispara efeito externo real, e nenhum e-mail ou telefone real aparece em fixture.
Passo 11: empacote (usuário, unit, egress)¶
Quem instala é o ops; você entrega o binário, o descriptor e os requisitos. Instalação à mão no servidor é vetada. O que o ops monta a partir do seu descriptor:
- Um usuário Linux próprio por provedor e uma porta de loopback atribuída.
- O par HMAC semeado, em segredo distinto de qualquer outro provedor.
- Egress por usuário: só os
egress_hostsdo descriptor, mais DNS e coletor de telemetria local. O resto é descartado e alertado. - Uma unit systemd hardened. O esqueleto, como referência:
[Service]
User=dracma_psp_exemplo
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
CapabilityBoundingSet=
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
MemoryDenyWriteExecute=yes
ReadOnlyPaths=/
EnvironmentFile=/etc/dracma/exemplo.env
ExecStart=/opt/dracma_psp_exemplo/bin/dracma_psp_exemplo
Vale também a regra de dependências: nome, licença e versão verificados, SBOM e imagem por digest quando houver contêiner (não-root, sistema de arquivos somente leitura).
Passo 12: registre¶
O registro é feito pelo ops, que chama as rotas do registro no executor (todas só para o chamador ops):
POST /internal/v1/providers registra por url de loopback
POST /internal/v1/providers/{name}/certify grava o resultado da certificação
POST /internal/v1/providers/{name}/enable habilita
GET /internal/v1/providers/{name}/inflight trabalho em voo
POST /internal/v1/providers/{name}/drain para de receber trabalho novo
Ciclo de vida: registered, enabled, draining, disabled, unplugged. Para chegar a enabled: descriptor válido e spp_version compatível, health em ok, egress e ingress aplicados e conferidos, certificação válida do descriptor_sha256 em hml e prd (em dev basta o harness local) e, se o provedor não é sintético, a política de ambiente satisfeita. Desplugar com saque ou cobrança em voo é recusado sem --force auditado.
Código de referência: dracma_psp_exemplo¶
O código completo e testado mora em um repositório, não nesta página: dracma_spp_kit/examples/dracma_psp_exemplo (Rust, axum). Ele é mantido junto do kit de conformidade e passa a suíte inteira a cada mudança (cargo test -p dracma_psp_exemplo), então não diverge do contrato. O repositório do kit é privado: peça acesso ao time do Dracma.
Faz cash_in PIX (create_charge, get_charge, callbacks/verify), mais health e descriptor; o resto responde 501 unsupported. O parceiro dele é uma API HTTP fictícia ("Exemplo Bank"), que nos testes é o fake do kit (spp-fake-psp); trocar essa parte pelo seu parceiro é o trabalho real do passo 5.
Leia os arquivos nesta ordem; cada um corresponde a um passo deste guia:
| Arquivo | Passo | O que mostra |
|---|---|---|
src/descriptor.rs |
2 | Descriptor completo e válido, com credential_schema (api_key e webhook_secret, ambos x-secret) |
src/hmac_v2.rs |
4 | Recusas na ordem do contrato, vetor público byte a byte, cache de nonce |
src/problem.rs |
6 | problem+json com code e effect |
src/partner.rs |
6 | Cliente do parceiro (anti-SSRF por egress_hosts, sem redirect, teto de 1 MiB) e tradução das falhas |
src/idempotency.rs |
7 | Replay seguro por Spp-Idempotency-Key |
src/handlers.rs |
3, 5, 8 | Rotas, credencial por chamada com decodificação estrita e guarda de ambiente |
src/main.rs |
3 | Bootstrap em loopback, segredo HMAC lido de arquivo (EXEMPLO_HMAC_SECRET_FILE) |
tests/kit.rs |
10 | Modelo do teste de integração: o provedor passando a suíte do kit contra o fake |
conformance/profile.json |
10 | Perfil do kit: credencial de sandbox, rotas do parceiro, cenários e callbacks |
O provedor completo existe em Rust; em Go há o verificador do HMAC testado (seção "Exemplo em Go", logo abaixo). Em qualquer linguagem do rol, siga a mesma divisão de arquivos e use o tests/kit.rs como modelo: o kit é caixa-preta e testa qualquer adaptador por HTTP.
Exemplo em Go: o verificador do HMAC v2¶
O dracma_psp_exemplo é em Rust, mas o Go é uma escolha tão boa quanto para um provedor (binário estático e biblioteca padrão suficiente). Este repositório traz o pedaço que mais quebra, o passo 4, em Go e testado: developers/exemplos/go/spp, só com a biblioteca padrão. O pacote reproduz o vetor público byte a byte e cobre as oito recusas na ordem, a janela de ±300 s (300 s aceita, 301 s recusa), os timestamps malformados, o replay e o fato de uma requisição forjada não queimar o nonce.
cd developers/exemplos/go && go vet ./spp && go test -count=1 ./spp
O núcleo é esta função. Ela é a do arquivo spp/hmacv2.go, sem alteração:
// Verificar aplica as recusas na ordem do contrato; a primeira que falha decide.
// Ordem: 1 missing_signature, 2 unknown_caller, 3 unsupported_signature_version, 4 timestamp_out_of_window,
// 5 invalid_nonce, 6 invalid_signature, 7 replayed_nonce, 8 caller_not_allowed (autenticou, mas não é o executor).
// Onde: middleware de autenticação do provedor. Efeitos: grava o nonce no cache quando a assinatura confere.
func Verificar(segredo []byte, cache *CacheNonce, h Cabecalhos, metodo, caminho string, corpo []byte, agora time.Time) *Recusa {
if h.Assinatura == "" {
return &Recusa{401, "missing_signature"}
}
if !chamadoresConhecidos[h.Chamador] {
return &Recusa{401, "unknown_caller"}
}
hexSig, ok := strings.CutPrefix(h.Assinatura, "v2=")
if !ok {
return &Recusa{401, "unsupported_signature_version"}
}
ts, err := strconv.ParseInt(h.Timestamp, 10, 64)
if !timestampBemFormado(h.Timestamp) || err != nil || abs(agora.Unix()-ts) > janelaSegundos {
return &Recusa{401, "timestamp_out_of_window"}
}
if !nonceBemFormado(h.Nonce) {
return &Recusa{401, "invalid_nonce"}
}
esperada := Assinar(segredo, Canonico(h.Timestamp, h.Nonce, metodo, caminho, corpo))
// Comparação em tempo constante: nunca == em assinatura.
if !hmac.Equal([]byte(esperada), []byte(hexSig)) {
return &Recusa{401, "invalid_signature"}
}
if !cache.registrar(h.Chamador+":"+h.Nonce, agora) {
return &Recusa{401, "replayed_nonce"}
}
if h.Chamador != "executor" {
return &Recusa{403, "caller_not_allowed"}
}
return nil
}
O pacote traz também o middleware Autenticar, que envolve todas as rotas /spp/v1/* (inclusive descriptor e health), lê o corpo com teto de 1 MiB, chama Verificar com o método e o path?query exatamente como chegou, e devolve os mesmos bytes ao handler. É o arquivo spp/middleware.go, testado com httptest (anônimo recusado, válido passa com o corpo intacto):
// Autenticar envolve um http.Handler: toda rota passa por Verificar antes do handler (deny-default).
//
// O quê: lê o corpo (teto de 1 MiB), verifica o HMAC v2 e, se confere, devolve os mesmos bytes ao handler.
// Onde: raiz do roteador do provedor, cobrindo TODAS as rotas /spp/v1/*, inclusive descriptor e health.
// Efeitos: consome e recoloca r.Body; responde problem+json em caso de recusa; grava o nonce no cache.
func Autenticar(segredo []byte, cache *CacheNonce, agora func() time.Time, proximo http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
corpo, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
if err != nil {
problema(w, http.StatusRequestEntityTooLarge, "payload_too_large")
return
}
r.Body = io.NopCloser(bytes.NewReader(corpo))
h := Cabecalhos{
Assinatura: r.Header.Get("Dracma-Internal-Signature"),
Chamador: r.Header.Get("Dracma-Caller"),
Timestamp: r.Header.Get("Dracma-Internal-Timestamp"),
Nonce: r.Header.Get("Dracma-Internal-Nonce"),
}
if rec := Verificar(segredo, cache, h, r.Method, r.URL.RequestURI(), corpo, agora()); rec != nil {
problema(w, rec.HTTP, rec.Code)
return
}
proximo.ServeHTTP(w, r)
})
}
O problema do pacote escreve application/problem+json com Spp-Version: 1.0 e só o code. O restante do provedor, do descriptor ao mapeamento de erros, segue a mesma divisão de arquivos do exemplo em Rust, e o kit de conformidade o testa por HTTP como caixa-preta, em qualquer linguagem.
Erros comuns¶
| Sintoma | Causa provável | Correção |
|---|---|---|
Toda chamada do executor volta invalid_signature |
O path assinado não é o completo, ou o corpo foi relido e alterado antes do hash | Hash sobre os bytes recebidos e path com query exatamente como chegou |
timestamp_out_of_window com relógio certo |
Timestamp com sinal, espaço, zero à esquerda ou notação científica | Valide o formato antes de converter |
Segunda chamada legítima vira replayed_nonce |
Nonce gravado antes de conferir o HMAC, ou nonce reutilizado no teste | Grave só depois do HMAC; use nonce novo em cada chamada |
| Cobrança duplicada no parceiro após timeout | Idempotência só no cache local, sem chave no parceiro e com reinício no meio | Repasse a chave ao parceiro ou use repositório durável |
| Saque reenviado | O provedor tratou timeout como falha definitiva | Timeout vira upstream_timeout com unknown; a consulta resolve |
| Callback aceito com corpo adulterado | A assinatura foi verificada sobre JSON reserializado | Verifique sobre os bytes de body_base64 |
| Callback recusado com motivo detalhado | Mensagem diferente para assinatura ausente e inválida | Um só callback_unauthenticated |
| Credencial aparece em log de erro | Debug ou toString sem redação |
Redija a credencial na definição do tipo |
enable recusado |
Sem certificação do hash atual do descriptor, ou health fora de ok |
Rode o kit de conformidade de novo e confira o health |
| Provedor não sobe sem o executor | Boot depende de chamar o executor | Remova a dependência; reporte degraded no health |
Próximos passos¶
- Leia o protocolo completo em protocolo.md e o formato de cada campo em descriptor.md.
- Rode o kit de conformidade e obtenha o certificado em certificacao.md.
- Peça ao ops a instalação e o registro do provedor; acompanhe a transição
registered,enabled.