Pular para o conteúdo

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.

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 VAZIO

Um 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 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
}
CampoO que é
algsempre RS256. Fixo — ver o aviso mais abaixo
kido serverKeyKid que você recebeu no provisionamento; diz qual chave assinou
jtiidentificador único desta chamada; tem dois papéis, explicados adiante
iatinstante de emissão (epoch em segundos)
expinstante de expiração (epoch em segundos)

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.

const { createSign, randomUUID } = require('crypto')
const body = JSON.stringify(payload) // ← assine ESTA string
const 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/json

Frescor: 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:

RegraLimiteErro se violada
exp maior que iatobrigatórioEnvelope assinado inválido.
exp − iat≤ 300sValidade da assinatura excede o limite.
iat no futurotolera 60s de desvioEnvelope assinado no futuro.
exp no passadotolera 60s de desvioAssinatura 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 é único por chamada, mas o que a plataforma faz com uma segunda chegada do mesmo jti depende da rota — e a diferença é fundamental.

  1. Nas rotas transportadas pelo client (criar transação e registrar anúncio), o jti é chave de idempotência. A segunda chegada do mesmo jti devolve 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.

  2. 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 responde 401 Requisição já processada. Ali repetição é replay, não retry a ser absorvido.

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:

  1. Quebre o header em <protegido>..<assinatura> — o segmento do meio tem de estar vazio.
  2. Decodifique o protegido; confira alg == "RS256" e kid == platformKeyKid.
  3. Reconstrua o signing_input com os bytes recebidos, antes de qualquer parse.
  4. Verifique com o platformPublicKeyPem.
  5. 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.

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.