Autenticação HMAC
Toda chamada é assinada com FTID-HMAC-SHA256-V1: HMAC-SHA-256 tendo como chave os bytes do
segredo da sua credencial, calculado sobre uma string canônica.
A string canônica
Seção intitulada “A string canônica”Exatamente dez linhas em UTF-8, separadas por LF (\n), sem LF no final.
A nona linha é vazia: é o Content-Encoding, que não existe neste protocolo.
O MAC é hexadecimal em minúsculas, com 64 caracteres, e vai no cabeçalho X-FTID-Signature.
FTID-HMAC-SHA256-V1<key_id>POST/v1/assessments<X-FTID-Timestamp>external_event_id:<external_event_id>request_id:<X-FTID-Request-Id>application/json
<lowercase hexadecimal SHA-256 of the exact body bytes>X-FTID-Signature: v1=<key_id>:<mac>Regras que mais quebram integrações
Seção intitulada “Regras que mais quebram integrações”- A chave são os bytes decodificados do segredo, nunca o texto hexadecimal. O console entrega
o segredo de produção uma única vez, como 32 bytes aleatórios codificados em 64 caracteres
hexadecimais. Leia-os somente do gerenciador de segredos do seu backend e chame
decodeSecret(text, 'hex')antes de assinar. O assinador de referência também aceita uma codificação base64 canônica emitida explicitamente; nunca adivinhe a codificação nem cole o valor em navegador, IA ou arquivo local. - O hash cobre os bytes que você envia. Serializar o JSON de novo depois de assinar (ordem de
chaves, espaçamento ou escape diferentes) produz
401. key_iderequest_idsão UUIDv7 canônicos, em minúsculas.- O caminho assinado é
/v1/assessments, literalmente, sem origem, query nem barra final. - Não há downgrade: se qualquer parte do envelope v1 falhar, o servidor não tenta outro protocolo.
O 401 é uniforme — de propósito
Seção intitulada “O 401 é uniforme — de propósito”Toda falha de autenticação — assinatura ausente ou malformada, key_id desconhecido, MAC errado,
credencial revogada ou suspensa, ou credencial fora da sua janela de sobreposição — retorna a
mesma resposta: 401, type authentication-failed, título Autenticação falhou (o
contrato fixa este título em português), sem detail, com
WWW-Authenticate: FTID-HMAC-V1 realm="assessments". A resposta não revela se o
key_id existe. Baseie seu código em status e type, não nesse cabeçalho nem no título.
Somente depois que a assinatura é verificada para uma credencial conhecida podem aparecer dois
outros tipos de 401, sobre a tentativa e não sobre a credencial: timestamp-out-of-window e
request-replayed (abaixo). Eles nunca respondem a uma requisição
cuja assinatura ou credencial falhou. Com assinatura válida e uma credencial sem a capacidade da
rota, a resposta é 403 forbidden.
Timestamp e request_id
Seção intitulada “Timestamp e request_id”X-FTID-Timestampé o segundo em que você assina a tentativa, não ooccurred_atdo evento. Fora de ±300 s do relógio do servidor:401 timestamp-out-of-window.X-FTID-Request-Idé o nonce. Reenviar o mesmorequest_iddá401 request-replayed. Nunca reutilize umrequest_id, nem mesmo depois de401,403,429ou503.- Gere UUIDv7 com
crypto.randomUUIDv7(), disponível a partir do Node.js 24.16.0 na série 24 e a partir do 26.1.0; o Node.js 22 e o 25 não o têm. Não usecrypto.randomUUID(): ele gera a versão 4, que a API rejeita. - Mantenha o relógio do seu backend sincronizado (NTP).
import { randomUUIDv7 } from 'node:crypto'
// The second at which you sign this attempt, not the event's occurred_at.const timestamp = String(Math.floor(Date.now() / 1000))// A new nonce for every attempt. randomUUID() makes version 4, which the API rejects.const requestId = randomUUIDv7()X-FTID-Timestamp: 1788523200X-FTID-Request-Id: <a new lowercase UUIDv7>X-FTID-Signature: v1=<key_id>:<mac>O assinador de referência faz as duas coisas por você.
Credencial, capacidades e rotação
Seção intitulada “Credencial, capacidades e rotação”- Uma credencial tem um conjunto fechado de capacidades, congelado na sua versão. Mudar uma capacidade exige uma nova credencial.
- A rotação muda o
key_id. O fluxo gerenciado cria uma nova credencial e exige a instalação segura do novo material dela. A credencial anterior é revogada ao fim de uma sobreposição de no máximo 24 horas. Umkey_idrevogado nunca volta. - Suspeita de vazamento: avise o FingerTrackID imediatamente. A rotação de emergência não tem sobreposição.
- Prepare seu backend para manter duas credenciais durante uma rotação e para trocar de uma para a outra sem deploy.
Vetor de teste público
Seção intitulada “Vetor de teste público”Um segredo sintético público — a sequência de 32 bytes 00..1f — que não autentica nada, em
lugar nenhum. Assine o corpo do vetor com ele e você deve obter exatamente o MAC à direita.
O vetor completo, com corpo, string canônica e cabeçalhos: assessment-hmac-v1-test-vector.json.
| Vetor | Rota | MAC esperado |
|---|---|---|
| Avaliação | POST /v1/assessments |
df9016da863e7fc4949f1845fb9457d1e3f165d427d1c8efd47af44eff86851b |
Assinador de referência (Node.js 24.16 ou mais recente)
Seção intitulada “Assinador de referência (Node.js 24.16 ou mais recente)”Dois arquivos, sem nenhuma dependência além de node:crypto, testados contra o vetor acima a cada
mudança. Eles precisam de crypto.randomUUIDv7(): Node.js 24.16.0 ou posterior na série 24, ou
26.1.0 ou posterior. No Node.js 22 ou 25 o assinador falha assim que é importado (na Vercel,
defina a versão do Node.js do projeto como 24.x). Para que um login não dependa disso, carregue-o
com await import() dentro do seu try fail-open, como faz o
wrapper das orientações de integração.
- ftid-hmac-v1.mjs — o assinador
- ftid-hmac-v1-self-check.mjs — a autoverificação
Verifique seu ambiente
Seção intitulada “Verifique seu ambiente”Salve os dois arquivos na mesma pasta e rode a autoverificação no mesmo Node.js que roda o seu
backend. Ela imprime OK e o MAC do vetor.
A autoverificação traz os valores do vetor embutidos; ela só precisa do assinador ao lado.
node ftid-hmac-v1-self-check.mjsOK df9016da863e7fc4949f1845fb9457d1e3f165d427d1c8efd47af44eff86851bUso no seu backend
Seção intitulada “Uso no seu backend”Um envio por tentativa, sempre sobre os mesmos bytes.
readSecretFromYourVault, assessmentRequest, keyId e o timeout são seus. Em
produção, apiOrigin é 'https://api.fingertrackid.com', exatamente como escrito: https://,
host em minúsculas, sem barra final, caminho, query nem credenciais. O console mostra o mesmo valor
como Endereço da API de risco.
prepareAttemptsó assina um corpo cujo texto seja exatamente o queJSON.stringifyescreve; qualquer outro texto recebeFTID_BODYsem ser assinado. É assim que ele bloqueia chaves duplicadas em qualquer nível. Monte o corpo com uma única chamada aJSON.stringify.postAttemptnão faz nova tentativa por conta própria, não segue redirecionamentos e lê no máximo 65536 bytes da resposta; um byte a mais viraFTID_INDETERMINATE. Valide a resposta com o schema antes de usá-la.- Se você já tem um cliente HTTP, use apenas
prepareAttempt, envieattempt.bodyeattempt.headerssem alteração e limite a leitura da resposta ao mesmo teto.
import { decodeSecret, postAttempt, prepareAttempt } from './ftid-hmac-v1.mjs'
// Once, at startup, from your secret manager. The console delivers it as hex.const secret = decodeSecret(await readSecretFromYourVault(), 'hex')
// Once per logical operation: validate against the schema, then keep these exact bytes.const body = Buffer.from(JSON.stringify(assessmentRequest), 'utf8')
// Once per transport attempt, including every retry of the same operation.const attempt = prepareAttempt({ secret, keyId, body })// Above the 5000 ms default budget of a `null` LOGIN/v1.1 body plus the network.const result = await postAttempt(apiOrigin, attempt, 6500)// result.status 201/200: parse result.body by its schema_version (event-assessment.v1.1 or// event-assessment.v1) and check the event and its tag (v1.1) or journey (v1).// Retryable Problem or FTID_INDETERMINATE: wait, then call prepareAttempt again with the same body.Código completo
Seção intitulada “Código completo”// FTID-HMAC-SHA256-V1 signing for the FingerTrackID server-to-server API.//// Node.js 24.16.0 or newer, ES module: `crypto.randomUUIDv7()` was added in 24.16.0, and an// older release fails at import time instead of signing. It imports only `node:crypto` and uses// platform globals (`Buffer`, `fetch`, `AbortSignal`, `TextDecoder`). It belongs in your// backend: the inbound secret must never reach a browser, a mobile app, a log or an analytics// tool.//// Contract: contracts/openapi/assessment.v1.yaml (securitySchemes.ftidHmacV1) and// contracts/openapi/feedback.v1.yaml. Normative vectors reproduced byte for byte:// contracts/openapi/assessment-hmac-v1-test-vector.json and hmac-v1-test-vector.json.import { createHash, createHmac, randomUUIDv7 } from 'node:crypto'
export const PROTOCOL = 'FTID-HMAC-SHA256-V1'export const ASSESSMENT_PATH = '/v1/assessments'export const MAX_BODY_BYTES = 16384// Every response of the three routes is at most this many bytes, inclusive: the// `x-ftid-max-response-body-bytes` both contracts publish.export const MAX_RESPONSE_BYTES = 65536export const MAX_TIMESTAMP = 253402300799// `AbortSignal.timeout()` takes a 32-bit signed delay; above it Node aborts at once or throws.export const MAX_TIMEOUT_MS = 2147483647const MIN_SECRET_BYTES = 32const UUID_V7 = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/const EXTERNAL_EVENT_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/const FEEDBACK_PATH = /^\/v1\/assessments\/([0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})\/(?:customer-actions|outcomes)$/const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]'])
/** * Decodes the secret received once in the console. The HMAC key is the decoded bytes, never the * hexadecimal or base64 text itself. Surrounding whitespace from copy and paste is removed; * anything else that is not canonical hex/base64 is rejected instead of silently truncated. */export function decodeSecret(text, encoding) { if (typeof text !== 'string') throw new TypeError('FTID_SECRET_ENCODING') const value = text.trim() let secret if (encoding === 'hex') { secret = Buffer.from(value, 'hex') if (secret.toString('hex') !== value.toLowerCase()) throw new TypeError('FTID_SECRET_ENCODING') } else if (encoding === 'base64') { secret = Buffer.from(value, 'base64') if (secret.toString('base64') !== value) throw new TypeError('FTID_SECRET_ENCODING') } else { throw new TypeError('FTID_SECRET_ENCODING') } if (secret.byteLength < MIN_SECRET_BYTES) throw new TypeError('FTID_SECRET') return secret}
/** * A canonical lowercase UUIDv7 for `X-FTID-Request-Id`, new for every transport attempt, from * the platform generator. `crypto.randomUUID()` produces version 4, which the API rejects, and * silently ignores a `{ version: 7 }` option; the format check below catches that swap. */export function newRequestId() { const id = randomUUIDv7() if (!UUID_V7.test(id)) throw new Error('FTID_REQUEST_ID') return id}
/** The signed resource tag is fixed by the path, never chosen by the caller. */function signedIdTag(path, signedId) { if (path === ASSESSMENT_PATH) { if (typeof signedId !== 'string' || !EXTERNAL_EVENT_ID.test(signedId)) { throw new TypeError('FTID_EXTERNAL_EVENT_ID') } return 'external_event_id' } const match = typeof path === 'string' ? FEEDBACK_PATH.exec(path) : null if (match === null) throw new TypeError('FTID_PATH') if (signedId !== match[1]) throw new TypeError('FTID_ASSESSMENT_ID') return 'assessment_id'}
/** * The ten lines the MAC covers, joined by LF and without a final LF. The last line hashes * the exact body bytes: never parse and re-serialize JSON between signing and sending. */export function canonicalString({ keyId, path, timestamp, signedId, requestId, body }) { const tag = signedIdTag(path, signedId) if (typeof keyId !== 'string' || !UUID_V7.test(keyId)) throw new TypeError('FTID_KEY_ID') if (!Number.isSafeInteger(timestamp) || timestamp < 0 || timestamp > MAX_TIMESTAMP) { throw new TypeError('FTID_TIMESTAMP') } if (typeof requestId !== 'string' || !UUID_V7.test(requestId)) { throw new TypeError('FTID_REQUEST_ID') } if (!(body instanceof Uint8Array) || body.byteLength === 0 || body.byteLength > MAX_BODY_BYTES) { throw new TypeError('FTID_BODY') } return [ PROTOCOL, keyId, 'POST', path, String(timestamp), `${tag}:${signedId}`, `request_id:${requestId}`, 'application/json', '', createHash('sha256').update(body).digest('hex'), ].join('\n')}
/** HMAC-SHA-256 over the UTF-8 canonical string, as 64 lowercase hexadecimal characters. */export function sign({ secret, ...input }) { if (!(secret instanceof Uint8Array) || secret.byteLength < MIN_SECRET_BYTES) { throw new TypeError('FTID_SECRET') } return createHmac('sha256', secret).update(canonicalString(input), 'utf8').digest('hex')}
/** Exactly these five headers. Do not add X-Client-Id, a nonce header or Content-Encoding. */export function buildHeaders({ keyId, path, timestamp, signedId, requestId, mac }) { const resourceHeader = path === ASSESSMENT_PATH ? 'X-FTID-External-Event-Id' : 'X-FTID-Assessment-Id' return { 'Content-Type': 'application/json', 'X-FTID-Timestamp': String(timestamp), 'X-FTID-Request-Id': requestId, [resourceHeader]: signedId, 'X-FTID-Signature': `v1=${keyId}:${mac}`, }}
/** * Every route reads one JSON object in strict UTF-8 without a BOM, so anything else is refused * here instead of being sent. `fatal: true` rejects invalid UTF-8 instead of replacing it, and * `ignoreBOM: true` keeps a leading BOM as U+FEFF, which `JSON.parse` rejects. * * The text must also be exactly what `JSON.stringify` writes for it. The API refuses a repeated * member at any depth, even one spelled with escapes, while `JSON.parse` silently keeps the * last one. Serializing the parsed value again writes each member once, so a body that repeats * one can never equal its own re-serialization and is refused here without a JSON reader of * its own. This also refuses some bodies the API accepts but `JSON.stringify` never writes: * whitespace, a trailing newline, escapes such as `\/` and number spellings such as `1.5e5`. * Build the body with one `JSON.stringify` call, whose output always passes. * * Every check runs inside the `try`, so a failure of any kind, including a stack overflow on a * deeply nested body, is reported as `FTID_BODY`. */function signedIdFrom(path, body) { let document let accepted try { const text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(body) document = JSON.parse(text) accepted = JSON.stringify(document) === text && document !== null && typeof document === 'object' && !Array.isArray(document) && (path === ASSESSMENT_PATH || feedbackStrings(document)) } catch { throw new TypeError('FTID_BODY') } if (!accepted) throw new TypeError('FTID_BODY') if (path === ASSESSMENT_PATH) return document.external_event_id return FEEDBACK_PATH.exec(path)?.[1]}
/** * The feedback routes refuse any member name or string value, once decoded, that is not * well-formed UTF-16 (a lone surrogate from `\ud800`) or that contains U+0000. `JSON.stringify` * only escapes both, so they reach the API unless refused here. */function feedbackStrings(value) { if (typeof value === 'string') return value.isWellFormed() && !value.includes('\u0000') if (value === null || typeof value !== 'object') return true return Object.entries(value).every(([key, item]) => feedbackStrings(key) && feedbackStrings(item))}
/** * Prepares one transport attempt. Serialize the logical request once, keep those exact bytes * with your operation record and call this again for every retry: each call returns a new * request ID, the current timestamp and a new signature over the same bytes. */export function prepareAttempt({ secret, keyId, path = ASSESSMENT_PATH, body, nowMs = Date.now(),}) { // Bounded before the copy, so an oversized body is never copied or parsed; `sign` checks the // copy again. if (!(body instanceof Uint8Array) || body.byteLength === 0 || body.byteLength > MAX_BODY_BYTES) { throw new TypeError('FTID_BODY') } const bytes = Buffer.from(body) const signedId = signedIdFrom(path, bytes) const timestamp = Math.floor(nowMs / 1000) const requestId = newRequestId() const mac = sign({ secret, keyId, path, timestamp, signedId, requestId, body: bytes }) return { path, body: bytes, requestId, headers: buildHeaders({ keyId, path, timestamp, signedId, requestId, mac }), }}
/** * Reads the response body, counting the bytes that actually arrive instead of trusting a header, * and stops at the first byte past `MAX_RESPONSE_BYTES`. The API never sends more and never * persists an assessment whose response would not fit, so a longer body is a failed read: the * stream is cancelled and `postAttempt` reports `FTID_INDETERMINATE`. */async function readResponse(response) { if (response.body === null) return new Uint8Array(0) const reader = response.body.getReader() const chunks = [] let size = 0 for (;;) { const { done, value } = await reader.read() if (done) return Buffer.concat(chunks, size) size += value.byteLength if (size > MAX_RESPONSE_BYTES) { await reader.cancel() throw new RangeError('response above MAX_RESPONSE_BYTES') } chunks.push(value) }}
/** * Sends one prepared attempt without retrying or following redirects. `FTID_INDETERMINATE` * means the outcome is unknown (network error, timeout, a response that cannot be read or is * above `MAX_RESPONSE_BYTES`): recover by preparing a NEW attempt over the same bytes, never by * resending this one. Plain HTTP is accepted only for a loopback test double. */export async function postAttempt(apiOrigin, attempt, timeoutMs) { // Node's own error for an unparsable URL carries the rejected text, credentials included, in // `error.input`; only the fixed code leaves here. let origin try { origin = new URL(apiOrigin) } catch { throw new TypeError('FTID_API_ORIGIN') } const loopback = LOOPBACK_HOSTS.has(origin.hostname) && origin.protocol === 'http:' // An origin only: no path, query, fragment, credentials or trailing slash. The scheme's // default port may be spelled out (`https://api.example:443`): URL drops it from `.origin`. const defaultPort = origin.protocol === 'https:' ? '443' : '80' const spelled = apiOrigin === origin.origin || apiOrigin === `${origin.origin}:${defaultPort}` if (!spelled || (origin.protocol !== 'https:' && !loopback)) { throw new TypeError('FTID_API_ORIGIN') } if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > MAX_TIMEOUT_MS) { throw new TypeError('FTID_TIMEOUT') } // The path is checked again here, not trusted from `prepareAttempt`: an edited `attempt.path` // such as `@evil.example/x` would move the signed request to another host. Only the fixed // routes pass, and the final URL must still be on the API origin. const routePath = attempt?.path if ( typeof routePath !== 'string' || (routePath !== ASSESSMENT_PATH && !FEEDBACK_PATH.test(routePath)) ) { throw new TypeError('FTID_PATH') } const target = new URL(routePath, origin.origin) if (target.origin !== origin.origin) throw new TypeError('FTID_PATH') let response let bytes try { response = await fetch(target.href, { method: 'POST', headers: attempt.headers, body: attempt.body, redirect: 'error', signal: AbortSignal.timeout(timeoutMs), }) bytes = await readResponse(response) } catch { throw new Error('FTID_INDETERMINATE') } let text try { text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes) } catch { throw new Error('FTID_INDETERMINATE') } return { status: response.status, contentType: response.headers.get('content-type'), correlationId: response.headers.get('x-correlation-id'), retryAfter: response.headers.get('retry-after'), body: text, }}// Checks ftid-hmac-v1.mjs against the published synthetic vector for POST /v1/assessments// (contracts/openapi/assessment-hmac-v1-test-vector.json). Run it next to that file://// node ftid-hmac-v1-self-check.mjs//// It prints `OK <mac>` and exits 0 on a byte-for-byte match, or prints MISMATCH and exits 1.// The secret is the public test sequence 00..1f: it authenticates nothing anywhere.import { buildHeaders, canonicalString, decodeSecret, sign } from './ftid-hmac-v1.mjs'
const VECTOR = { secretHex: '000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f', keyId: '01996f2a-1111-7000-8000-000000000001', path: '/v1/assessments', timestamp: 1788523200, signedId: 'login_20260904_000001', requestId: '01a06c4a-3e00-7000-8000-000000000002', body: '{"schema_version":"event-assessment-request.v1","external_event_id":"login_20260904_000001","idempotency_key":"idem_login_20260904_000001","occurred_at":"2026-09-04T12:00:00Z","subject_ref":"subject_123","account_ref":"account_456","event_context_token":"ect_login_20260904_000001","journey":"LOGIN","action":"LOGIN_ATTEMPT","journey_context":{},"deadline_ms":null}', canonicalString: [ 'FTID-HMAC-SHA256-V1', '01996f2a-1111-7000-8000-000000000001', 'POST', '/v1/assessments', '1788523200', 'external_event_id:login_20260904_000001', 'request_id:01a06c4a-3e00-7000-8000-000000000002', 'application/json', '', 'f7cdd806f169384d0cc3bf5f63654999d5b78aa68045da4ddce075b09953bd44', ].join('\n'), mac: 'df9016da863e7fc4949f1845fb9457d1e3f165d427d1c8efd47af44eff86851b', signatureHeader: 'v1=01996f2a-1111-7000-8000-000000000001:df9016da863e7fc4949f1845fb9457d1e3f165d427d1c8efd47af44eff86851b',}
const input = { keyId: VECTOR.keyId, path: VECTOR.path, timestamp: VECTOR.timestamp, signedId: VECTOR.signedId, requestId: VECTOR.requestId, body: Buffer.from(VECTOR.body, 'utf8'),}const canonical = canonicalString(input)const mac = sign({ secret: decodeSecret(VECTOR.secretHex, 'hex'), ...input })const headers = buildHeaders({ ...input, mac })
if ( canonical === VECTOR.canonicalString && mac === VECTOR.mac && headers['X-FTID-Signature'] === VECTOR.signatureHeader) { process.stdout.write(`OK ${mac}\n`)} else { console.error('MISMATCH: this signer does not reproduce the published vector') process.exitCode = 1}