# Custódia da autorização de consulta

<https://unifokal.com/docs/modulos/custodia-autorizacao>

## Custódia da autorização de consulta

! **Ainda não está aberto para venda.** O módulo aparece na [tabela de preços](https://unifokal.com/precos) com preço e com o selo "Em breve", e o `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.
