Assinatura JWS na prática
Toda chamada entre o seu servidor e a plataforma — nos dois sentidos — é assinada. A assinatura é o que prova que um anúncio veio mesmo do seu servidor, e que uma ordem de transferência veio mesmo da Lootfy. Esta página é a mecânica: como a assinatura é montada, o que exatamente ela cobre, e o punhado de detalhes que, se passarem batido, geram um 401 difícil de diagnosticar.
Para o dono de servidor: o essencial é que a assinatura garante origem e integridade — quem mandou, e que ninguém adulterou no caminho. Ela não esconde o conteúdo (isso é o TLS que faz), e não é opcional: uma chamada sem assinatura válida é recusada. Na prática, quem monta tudo isto é o Agent — você não escreve este código à mão.
O formato: JWS destacado compacto
Seção intitulada “O formato: JWS destacado compacto”A assinatura segue o JWS compacto destacado (RFC 7515, Apêndice F), transportado no header X-Lootfy-Signature:
X-Lootfy-Signature: <protegido>..<assinatura> ↑↑ o segmento do meio vai VAZIOUm JWS compacto normal tem três segmentos: header.payload.signature. Na variante destacada, o segmento do meio (o payload) fica vazio — porque o payload não vai no header, ele é o próprio corpo da requisição, que trafega como JSON puro. Por isso a chamada continua legível e depurável do seu lado: o corpo é JSON normal, e a assinatura viaja ao lado, no header.
O header protegido
Seção intitulada “O header protegido”O primeiro segmento é o header protegido — um JSON codificado em base64url. Ele carrega o algoritmo, a identidade da chave e os metadados de frescor:
{ "alg": "RS256", "kid": "srv-kid-9f2c1a", "jti": "8f2c1a9b-4e7d-4b21-9c3a-1d2e3f4a5b6c", "iat": 1789000000, "exp": 1789000120}| Campo | O que é |
|---|---|
alg | sempre RS256. Fixo — ver o aviso mais abaixo |
kid | o serverKeyKid que você recebeu no provisionamento; diz qual chave assinou |
jti | identificador único desta chamada; tem dois papéis, explicados adiante |
iat | instante de emissão (epoch em segundos) |
exp | instante de expiração (epoch em segundos) |
O que exatamente se assina
Seção intitulada “O que exatamente se assina”A entrada da assinatura é o header protegido concatenado com o corpo, ambos em base64url:
signing_input = base64url(protegido) + "." + base64url(corpo_cru)assinatura = base64url( RS256(signing_input, sua_chave_privada) )Para requisições GET e para confirmar entrega (POST /v1/api/transactions/{transactionId}/delivery, que não tem corpo), o corpo_cru é o corpo vazio ou {} — assine exatamente o que vai no fio.
Exemplo conceitual (Node)
Seção intitulada “Exemplo conceitual (Node)”const { createSign, randomUUID } = require('crypto')
const body = JSON.stringify(payload) // ← assine ESTA stringconst now = Math.floor(Date.now() / 1000)
const protectedHeader = Buffer.from(JSON.stringify({ alg: 'RS256', kid: serverKeyKid, jti: randomUUID(), iat: now, exp: now + 120,})).toString('base64url')
const signingInput = Buffer.concat([ Buffer.from(protectedHeader + '.', 'ascii'), Buffer.from(Buffer.from(body, 'utf8').toString('base64url'), 'ascii'),])
const signature = createSign('sha256').update(signingInput) .sign(privateKeyPem).toString('base64url')
// headers:// X-Lootfy-Signature: `${protectedHeader}..${signature}`// X-Service-Api-Key: <sua-api-key> (nas rotas service; ausente nas signature-only)// Content-Type: application/jsonFrescor: a assinatura prova origem, não que é recente
Seção intitulada “Frescor: a assinatura prova origem, não que é recente”Uma requisição assinada e capturada — de um log, de um tracker de erro, de um proxy que termina TLS — validaria para sempre se reenviada. O frescor fecha esse vão, e é conferido assim:
| Regra | Limite | Erro se violada |
|---|---|---|
exp maior que iat | obrigatório | Envelope assinado inválido. |
exp − iat | ≤ 300s | Validade da assinatura excede o limite. |
iat no futuro | tolera 60s de desvio | Envelope assinado no futuro. |
exp no passado | tolera 60s de desvio | Assinatura expirada. |
O teto de 300s existe porque, sem ele, um exp de um ano transformaria a assinatura numa credencial permanente. Sincronize o relógio do servidor por NTP — a tolerância é de apenas um minuto, e relógio torto vira 401 intermitente (capacidade A3 dos pré-requisitos).
O jti tem dois papéis, e eles são opostos
Seção intitulada “O jti tem dois papéis, e eles são opostos”O jti é único por chamada, mas o que a plataforma faz com uma segunda chegada do mesmo jti depende da rota — e a diferença é fundamental.
-
Nas rotas transportadas pelo client (criar transação e registrar anúncio), o
jtié chave de idempotência. A segunda chegada do mesmojtidevolve o mesmo resultado — a mesma transação, o mesmo anúncio —, nunca um erro e nunca um segundo registro. Isso é necessidade, não cortesia: o client roda na máquina do jogador, onde retry de rede é esperado e é bit a bit idêntico a um replay. Recusar o segundo puniria o primeiro. -
Nas demais rotas assinadas (consultar transação, confirmar entrega, reportar falha de entrega, vincular personagem, ajustar ou retirar anúncio), o
jtié nonce. A segunda chegada do mesmo envelope responde401 Requisição já processada.Ali repetição é replay, não retry a ser absorvido.
Validar os webhooks que você recebe
Seção intitulada “Validar os webhooks que você recebe”A plataforma assina os webhooks (transfer_order, transaction_terminal) com a chave PLATFORM, no mesmo formato destacado. Você valida com o platformPublicKeyPem, exigindo kid == platformKeyKid. Os cinco passos:
- Quebre o header em
<protegido>..<assinatura>— o segmento do meio tem de estar vazio. - Decodifique o protegido; confira
alg == "RS256"ekid == platformKeyKid. - Reconstrua o
signing_inputcom os bytes recebidos, antes de qualquer parse. - Verifique com o
platformPublicKeyPem. - Confira o
exp(emitimos com 5 minutos de validade).
O passo a passo aplicado, com o payload de cada webhook, está em Webhook e entrega.
Por que RS256 é fixo e o header alg nunca é confiado
Seção intitulada “Por que RS256 é fixo e o header alg nunca é confiado”O algoritmo é RS256 (RSA 2048), fixo. Ele foi escolhido por interoperabilidade — RSA existe em praticamente toda biblioteca de cripto, inclusive as antigas —, e trocar de algoritmo depois exigiria reintegrar todos os parceiros. Trate-o como decisão imutável.
Um envelope, conceitualmente
Seção intitulada “Um envelope, conceitualmente”Juntando tudo, uma chamada assinada tem esta anatomia:
┌─ header HTTP ────────────────────────────────────────────────┐│ X-Lootfy-Signature: eyJhbGciOiJSUzI1NiIsImtpZCI6... .. Qm9k ││ └─ base64url(protegido) ─┘ └vazio┘ └ass.┘││ X-Service-Api-Key: sk_live_... (só nas rotas service) ││ Content-Type: application/json │├─ corpo (o payload assinado, em claro) ───────────────────────┤│ { "itemRef": "srv-offer-9931", "quantity": 2, ││ "buyerCharacterRef": "char-123" } │└──────────────────────────────────────────────────────────────┘O corpo viaja legível; a assinatura, ao lado, prova que ele é seu e que ninguém o tocou.