Pular para o conteúdo
No ar em produção, em modo shadow: o FingerTrackID recomenda, o seu sistema decide.Status →

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.

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.

String canônica
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>
Cabeçalho
X-FTID-Signature: v1=<key_id>:<mac>
  • 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_id e request_id sã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.

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.

  • X-FTID-Timestamp é o segundo em que você assina a tentativa, não o occurred_at do evento. Fora de ±300 s do relógio do servidor: 401 timestamp-out-of-window.
  • X-FTID-Request-Id é o nonce. Reenviar o mesmo request_id dá 401 request-replayed. Nunca reutilize um request_id, nem mesmo depois de 401, 403, 429 ou 503.
  • 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 use crypto.randomUUID(): ele gera a versão 4, que a API rejeita.
  • Mantenha o relógio do seu backend sincronizado (NTP).
Uma vez por tentativa
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()
Cabeçalhos de uma tentativa
X-FTID-Timestamp: 1788523200
X-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ê.

  • 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. Um key_id revogado 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.

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.

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.

Terminal
node ftid-hmac-v1-self-check.mjs
Saída esperada
OK df9016da863e7fc4949f1845fb9457d1e3f165d427d1c8efd47af44eff86851b

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.

  • prepareAttempt só assina um corpo cujo texto seja exatamente o que JSON.stringify escreve; qualquer outro texto recebe FTID_BODY sem ser assinado. É assim que ele bloqueia chaves duplicadas em qualquer nível. Monte o corpo com uma única chamada a JSON.stringify.
  • postAttempt nã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 vira FTID_INDETERMINATE. Valide a resposta com o schema antes de usá-la.
  • Se você já tem um cliente HTTP, use apenas prepareAttempt, envie attempt.body e attempt.headers sem alteração e limite a leitura da resposta ao mesmo teto.
server.mjs
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.
ftid-hmac-v1.mjs
// 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 = 65536
export 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 = 2147483647
const MIN_SECRET_BYTES = 32
const 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,
}
}
ftid-hmac-v1-self-check.mjs
// 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
}