Custódia da autorização de consulta
Custódia da autorização de consulta
create de flow o recusa até a abertura. O contrato abaixo é o que o backend já emite, para você planejar a integração.Quem consulta o SCR do Banco Central precisa guardar a autorização do cliente por cinco anos contados da data da última consulta, num meio que permita comprovar a autenticidade dela (Resolução CMN 5.037/2022, art. 12, § 3º). O módulo custodia_autorizacao faz essa guarda por você: amarra a autorização à identidade verificada e ao consentimento da sessão, assina cada ato com Ed25519 e entrega um pacote de prova que qualquer pessoa confere sem consultar a UNIFOKAL.
O texto da autorização nunca passa por nós. Você cria a sessão com o SHA-256 do texto que o titular leu, a versão desse texto no seu sistema e o escopo. O CPF lido do documento entra no registro só como compromisso criptográfico, com um sal aleatório próprio de cada autorização que não viaja no pacote de prova: quem tem só o pacote não chega ao CPF, e quem tem o CPF confere o titular com a abertura que o painel entrega a quem informa esse mesmo CPF.
// criação de sessão com o bloco consultation_authorization (obrigatório quando o flow tem o módulo)
POST /v1/verification-sessions
{ "flow_id": "flow_...", "reference_id": "proposta-8841",
"consultation_authorization": {
"text_sha256": "f2ca1bb6c7e907d06dafe4687e579fce76b37e4e93b7605022da52e6ccc26fd2",
"text_version": "autorizacao-scr-v3",
"scope": "scr" } }Se a verificação aprovar, o registro é emitido na mesma transação da decisão e o check_details traz o identificador que você usa no painel:
// custodia_autorizacao no check_details: registro emitido
{ "module": "custodia_autorizacao", "passed": true, "outcome": "approved",
"data": { "consultation_authorization": {
"issued": true,
"id": "cau_01J8Z6XK4M9QHDEMSANDBXXXX1",
"scope": "scr",
"collection_mode": "declarada_pelo_cliente", // ou "assinada_pelo_titular"
"text_version": "autorizacao-scr-v3",
"issued_at": "2026-09-23T12:00:00.000Z",
"retention_years": 5,
"retained_until": "2031-09-23", // cresce a cada consulta registrada
"key_id": "prd-..." } } }// custodia_autorizacao no check_details: registro NÃO emitido (e não cobrado)
{ "module": "custodia_autorizacao", "passed": null, "outcome": "pending",
"data": { "consultation_authorization": {
"issued": false,
"reason": "verification_not_approved" } } }O collection_mode diz como o titular aceitou o texto: assinada_pelo_titular quando o mesmo flow tem o módulo assinatura e o documento assinado é o próprio texto da autorização (mesmo hash); declarada_pelo_cliente quando o texto foi mostrado na sua interface e você declarou o hash. Os motivos de não emissão são nomeados: verification_not_approved, consent_missing (a sessão não registrou o consentimento), subject_document_missing (sem CPF lido do documento) e consultation_authorization_missing.
O relógio conta da última consulta. A cada consulta ao SCR feita com a autorização, registre o dia no painel, em Conformidade, um por vez ou em lote (até 500 por envio, a partir de um arquivo CSV lido no seu navegador). O prazo passa a ser a data da consulta mais cinco anos, contados pelo Código Civil (art. 132, § 3º: 29 de fevereiro mais cinco anos vence em 1º de março). O prazo nunca diminui: registrar uma data anterior à última fica no histórico e não encurta nada, a mesma data de novo não duplica, data futura ou anterior à emissão é recusada, e autorização revogada só aceita o registro de consulta feita até o dia da revogação (a guarda do que já foi consultado continua). O prazo padrão é de cinco anos e você pode ampliá-lo até vinte no painel; salvar um prazo leva a ele as autorizações já emitidas que têm prazo menor, e nenhuma guarda encurta.
A prova, verificável fora da UNIFOKAL. No painel, a exportação devolve um arquivo JSON no formato unifokal/custodia-autorizacao@1: a cadeia de eventos (registro, consultas, revogação), cada um com o texto canônico exato que foi assinado, o SHA-256 dele, o do evento anterior e a assinatura; e o estado atual, assinado no instante da exportação. Esse arquivo não carrega dado pessoal em claro e pode ser entregue a um auditor. Para provar de quem é a autorização, informe no painel o CPF do titular: se ele casar com o compromisso assinado, você recebe a abertura do titular (formato unifokal/custodia-autorizacao-titular@1), com o sal do compromisso. Junto com o pacote, a abertura identifica o titular: entregue as duas só a quem já tem o CPF.
O caminho de referência é a função dos SDKs, que confere tudo sem rede e devolve o motivo de cada recusa: cada evento assinado por chave da lista abaixo, do mesmo ambiente e já em vigor no instante do evento; o SHA-256 de cada texto canônico, a sequência e o encadeamento; o estado apontando para o último evento, sem prometer prazo menor do que a cadeia prova; depois de uma revogação, só o registro de consulta feita até o dia dela; e, com o CPF e a abertura, o titular contra o compromisso assinado no registro, nunca contra a cópia de leitura do envelope.
// TypeScript (SDK oficial, só node:crypto, sem rede)
import { readFileSync } from "node:fs";
import { verifyConsultationAuthorizationProof } from "unifokal";
const pacote = JSON.parse(readFileSync("custodia-cau_....json", "utf8"));
const abertura = JSON.parse(readFileSync("custodia-cau_...-titular.json", "utf8"));
const r = verifyConsultationAuthorizationProof(pacote, { cpf: "529.982.247-25", subjectSalt: abertura.subject.salt });
// r.valid, r.errors (o motivo de cada recusa), r.subject_matches, r.retained_until, r.revoked# Python (SDK oficial, extra "unifokal[crypto]")
import json
from unifokal import verify_consultation_authorization_proof
pacote = json.load(open("custodia-cau_....json"))
abertura = json.load(open("custodia-cau_...-titular.json"))
r = verify_consultation_authorization_proof(pacote, cpf="529.982.247-25", subject_salt=abertura["subject"]["salt"])
# r["valid"], r["errors"], r["subject_matches"], r["retained_until"], r["revoked"]// chaves públicas de custódia (Ed25519, 32 bytes em base64). A âncora é ESTA lista:
// nunca confie na chave que viaja dentro do arquivo exportado.
[
{ "key_id": "sbx-86d06e439b84",
"public_key": "vKO9oHr/kojzQ4Pyvk+LG8yN0Dm1fE0YfCm9T+5O3Rk=",
"environment": "sandbox",
"not_before": "2026-09-23T00:00:00.000Z" }
]
// A chave de produção entra nesta lista antes da abertura da venda.Sem o SDK, os dois trechos abaixo fazem a mesma conferência, com a lista de chaves acima em pinadas. A comparação de datas é de texto: o formato ISO-8601 ordena igual ao tempo.
// Node 20+ (só node:crypto, sem rede)
const { createPublicKey, verify, createHash, createHmac } = require("node:crypto");
const SPKI = Buffer.from("302a300506032b6570032100", "hex");
const assinou = (k, texto, sig) => verify(null, Buffer.from(texto, "utf8"),
createPublicKey({ key: Buffer.concat([SPKI, Buffer.from(k.public_key, "base64")]), format: "der", type: "spki" }),
Buffer.from(String(sig), "base64"));
const pinada = (pinadas, amb, id, quando) =>
pinadas.find((p) => p.key_id === id && p.environment === amb && typeof quando === "string" && quando >= p.not_before);
function confere(pacote, pinadas) {
try {
const amb = pacote.environment;
if (pacote.format !== "unifokal/custodia-autorizacao@1" || pacote.algorithm !== "Ed25519") return false;
let anterior = "0".repeat(64), registro = null, guarda = null, revogadaEm = null;
for (const [i, ev] of pacote.events.entries()) {
const c = JSON.parse(ev.canonical);
const k = pinada(pinadas, amb, ev.key_id, c.occurred_at);
if (!k || !assinou(k, ev.canonical, ev.signature)) return false;
if (createHash("sha256").update(ev.canonical, "utf8").digest("hex") !== ev.entry_sha256) return false;
if (c.seq !== i + 1 || ev.seq !== i + 1 || c.authorization_id !== pacote.authorization_id || c.prev_sha256 !== anterior) return false;
if (revogadaEm && !(c.kind === "consulted" && c.consulted_on <= revogadaEm)) return false;
if (i === 0) {
if (c.kind !== "registered" || c.registered.environment !== amb) return false;
registro = c.registered; guarda = registro.retained_until;
} else if (c.kind === "consulted") {
if (c.retained_until_after < guarda) return false;
guarda = c.retained_until_after;
} else if (c.kind === "revoked" && !revogadaEm && c.revoked_on) {
revogadaEm = c.revoked_on;
} else return false;
anterior = ev.entry_sha256;
}
const e = JSON.parse(pacote.state.canonical);
const k = pinada(pinadas, amb, pacote.state.key_id, e.exported_at);
return !!k && assinou(k, pacote.state.canonical, pacote.state.signature) &&
e.authorization_id === pacote.authorization_id && e.environment === amb &&
e.chain_head_sha256 === anterior && e.chain_length === pacote.events.length &&
e.retained_until >= guarda && e.retention_years >= registro.retention_years &&
(e.revoked_at !== null) === (revogadaEm !== null) &&
pacote.subject.commitment === registro.subject_commitment;
} catch {
return false;
}
}
// o titular: o CPF (11 dígitos) e o sal da abertura, contra o compromisso ASSINADO no registro
const titular = (pacote, cpf, sal) =>
createHmac("sha256", Buffer.from(sal, "base64")).update("unifokal/custodia/v1|cpf|" + cpf, "utf8").digest("hex") ===
JSON.parse(pacote.events[0].canonical).registered.subject_commitment;# Python 3.10+ (pip install cryptography)
import base64, hashlib, hmac, json
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
def assinou(k, texto, sig):
try:
Ed25519PublicKey.from_public_bytes(base64.b64decode(k["public_key"])).verify(base64.b64decode(sig), texto.encode("utf-8"))
return True
except Exception:
return False
def pinada(pinadas, amb, key_id, quando):
return next((p for p in pinadas if p["key_id"] == key_id and p["environment"] == amb
and isinstance(quando, str) and quando >= p["not_before"]), None)
def confere(pacote, pinadas):
try:
amb = pacote["environment"]
if pacote["format"] != "unifokal/custodia-autorizacao@1" or pacote["algorithm"] != "Ed25519":
return False
anterior, registro, guarda, revogada_em = "0" * 64, None, None, None
for i, ev in enumerate(pacote["events"]):
c = json.loads(ev["canonical"])
k = pinada(pinadas, amb, ev["key_id"], c["occurred_at"])
if k is None or not assinou(k, ev["canonical"], ev["signature"]):
return False
if hashlib.sha256(ev["canonical"].encode("utf-8")).hexdigest() != ev["entry_sha256"]:
return False
if c["seq"] != i + 1 or ev["seq"] != i + 1 or c["authorization_id"] != pacote["authorization_id"] or c["prev_sha256"] != anterior:
return False
if revogada_em and not (c["kind"] == "consulted" and c["consulted_on"] <= revogada_em):
return False
if i == 0:
if c["kind"] != "registered" or c["registered"]["environment"] != amb:
return False
registro = c["registered"]
guarda = registro["retained_until"]
elif c["kind"] == "consulted":
if c["retained_until_after"] < guarda:
return False
guarda = c["retained_until_after"]
elif c["kind"] == "revoked" and not revogada_em and c["revoked_on"]:
revogada_em = c["revoked_on"]
else:
return False
anterior = ev["entry_sha256"]
e = json.loads(pacote["state"]["canonical"])
k = pinada(pinadas, amb, pacote["state"]["key_id"], e["exported_at"])
return (k is not None and assinou(k, pacote["state"]["canonical"], pacote["state"]["signature"])
and e["authorization_id"] == pacote["authorization_id"] and e["environment"] == amb
and e["chain_head_sha256"] == anterior and e["chain_length"] == len(pacote["events"])
and e["retained_until"] >= guarda and e["retention_years"] >= registro["retention_years"]
and (e["revoked_at"] is not None) == (revogada_em is not None)
and pacote["subject"]["commitment"] == registro["subject_commitment"])
except Exception:
return False
# o titular: o CPF (11 dígitos) e o sal da abertura, contra o compromisso ASSINADO no registro
def titular(pacote, cpf, sal):
calculado = hmac.new(base64.b64decode(sal), ("unifokal/custodia/v1|cpf|" + cpf).encode("utf-8"), hashlib.sha256).hexdigest()
return calculado == json.loads(pacote["events"][0]["canonical"])["registered"]["subject_commitment"]# openssl 3 (um evento por vez; os três arquivos saem do pacote exportado) printf '%s' "$CANONICO" > evento.txt # o texto canônico, byte a byte printf '%s' "$ASSINATURA_B64" | base64 -d > evento.sig # 64 bytes ( printf '302a300506032b6570032100' | xxd -r -p; printf '%s' "$CHAVE_B64" | base64 -d ) > chave.der openssl pkeyutl -verify -pubin -keyform DER -inkey chave.der -rawin -in evento.txt -sigfile evento.sig # "Signature Verified Successfully"
Quando não cobra. O registro só existe se a verificação aprovar, com o texto carimbado na criação da sessão, o consentimento registrado e o CPF lido do documento. Sem isso o módulo devolve "issued": false com o motivo e sai do preço daquela verificação. Registrar consultas, exportar a prova e ampliar o prazo não custam nada. O bloco consultation_authorization da criação é obrigatório quando o flow contém o módulo (422 consultation_authorization_required), recusado quando não contém (422 consultation_authorization_not_supported), e chave desconhecida dentro dele é 400 unknown_consultation_authorization_key: nunca aceito e ignorado em silêncio. O link hospedado não leva esse bloco: a emissão de link para um flow com o módulo é recusada com o mesmo 422 consultation_authorization_required, e a sessão desse flow nasce pela API.
Pronto para integrar? A chave de sandbox sai no painel, logo depois do cadastro. Criar conta grátis