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

sequenceDiagram participant E as Executor participant P as Provedor SPP participant X as Parceiro E->>P: POST /spp/v1/charges (HMAC v2, credencial no corpo) P->>P: verifica HMAC, nonce, ambiente, idempotencia P->>X: chamada HTTPS (egress declarado) X-->>P: resposta do parceiro P-->>E: 200/201 ou problem+json com code e effect

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: true só para dinheiro de mentira. Provedor sintético é recusado em produção.
  • credential_schema: JSON Schema com additionalProperties: false; marque cada segredo com "x-secret": true e dê x-label em português para o painel desenhar o formulário.
  • environments: sandbox só se o parceiro tiver; live é obrigatório para provedor não sintético.
  • egress_hosts: lista host:porta de 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:

  1. 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ão 127.0.0.1:7101 e recusa de qualquer IP fora de loopback).
  2. 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.
  3. Autenticar toda rota, inclusive descriptor e health, antes do handler (deny-default).
  4. Limitar o corpo a 1 MiB e aceitar JSON estrito (additionalProperties: false): campo desconhecido no pedido é erro.
  5. Responder Spp-Version: 1.0 e erros em application/problem+json.
  6. 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 degraded ou down no health.

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-Caller fora do conjunto conhecido é recusado cedo, no passo 2, como unknown_caller (401). Um chamador conhecido que não é o executor só chega ao passo 8 depois de a assinatura e o nonce conferirem, e recebe 403 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_eq em Rust, hmac.Equal em 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 em BRL).
  • create_charge devolve psp_ref; se o parceiro não devolve id, devolva o próprio deposit_id. Primeira execução pode ser 201, repetição é 200 com Spp-Replayed: true.
  • get_charge só afirma o que o parceiro confirma. O executor relata e a API decide o que é pago.
  • Em verify_callback, verifique sobre os bytes de body_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-Ms menos 500 ms e nunca repasse traceparent ao parceiro.
  • Pedido de pix chega assim (resumo do que o exemplo aceita; a credencial segue o credential_schema do descriptor, aqui api_key e webhook_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:

  1. Nunca transforme falha ambígua em definitiva (timeout ou 5xx do parceiro é sempre unknown) e nunca transforme recusa definitiva em ambígua.
  2. create_payout com falha de rede depois do envio devolve upstream_timeout. O executor marca o saque como unknown e consulta por payouts/get; ele não reenvia.
  3. Status ou campo que você não reconhece vira unrecognized. Nunca settled, nunca saldo zero.
  4. O detail do erro não leva corpo do parceiro, credencial, documento, nome nem chave PIX.
  5. Leia a tarifa com tolerância: falha ao ler fee nã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 Debug ou toString (no exemplo a credencial nem deriva Debug, 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 misconfigured com field. 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_callback também a recebe no corpo.

Passo 9: logs, traces e métricas

  • Logs em JSON com trace_id, connection_id, provider, op e outcome. Nunca credencial, corpo do parceiro, documento, nome ou chave PIX. O exemplo imprime provider, op, outcome e trace_id.
  • O trace_id vem do cabeçalho traceparent do executor (segundo campo). Continue o trace (span servidor) e crie upstream.<op> (span cliente) para o parceiro. Nunca repasse o traceparent ao 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} e spp_callbacks_verified_total{outcome}. Sem rótulo de tenant ou de connection_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:

  1. Testes do próprio provedor, como os de dracma_psp_exemplo (src/hmac_v2.rs e tests/): 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.
  2. 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).
  3. 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-Ms e confere, resposta a resposta, que o seu provedor devolve o code e o effect esperados. O kit de conformidade roda as mesmas verificações como suíte de certificação e grava o resultado do descriptor_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_hosts do 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.