Skip to content

Como Fazer: API de Cofre de Segredos Pessoais

Demonstra armazenamento chave-valor por usuário com integridade HMAC, prevenção de IDOR e acesso de metadados somente para admin. Field trial: FT195 (../NENE2-FT/vaultlog/). Inclui auditoria de segurança VULN-A~L.


Resumo do Padrão

PreocupaçãoAbordagem
Isolamento de usuárioWHERE user_id = :uid em toda query — IDOR impossível
Admin nunca vê valoresEndpoints admin retornam apenas user_id + key
Integridade HMAC`HMAC-SHA256(userId
Validação de chavepreg_match('/\A[a-z0-9_-]{1,64}\z/', $key) — seguro, sem risco de ReDoS
Validação de ID de usuárioctype_digit() + guarda de comprimento + verificação > 0
Chave adminhash_equals() tempo constante, fail-closed em chave vazia
UpsertUNIQUE(user_id, key_name) → armazenar primeiro (201) ou atualizar (200)

Rotas

MétodoCaminhoAuthDescrição
POST/vaultX-User-IdArmazenar ou atualizar um segredo
GET/vaultX-User-IdListar chaves de segredo do usuário (sem valores)
GET/vault/{key}X-User-IdObter valor de segredo do usuário
DELETE/vault/{key}X-User-IdExcluir segredo do usuário
GET/admin/vaultX-Admin-KeyListar todos os usuários + chaves (sem valores)
GET/admin/vault/{userId}X-Admin-KeyListar chaves de usuário específico

Schema do Banco de Dados

sql
CREATE TABLE vault_entries (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    user_id    INTEGER NOT NULL,
    key_name   TEXT    NOT NULL,
    value      TEXT    NOT NULL,
    hmac       TEXT    NOT NULL,   -- tag de integridade HMAC-SHA256
    created_at TEXT    NOT NULL,
    updated_at TEXT    NOT NULL,
    UNIQUE (user_id, key_name)
);

A restrição UNIQUE(user_id, key_name) impõe uma entrada por par (usuário, chave).


Integridade HMAC

php
private function computeHmac(int $userId, string $key, string $value): string
{
    return hash_hmac('sha256', "{$userId}|{$key}|{$value}", $this->hmacSecret);
}

No GET, o handler verifica o HMAC armazenado:

php
if (!$this->repo->verifyIntegrity($entry)) {
    return $this->problem(500, 'integrity-error', 'Secret integrity check failed.');
}

Isso detecta adulteração direta no banco (ex.: um DBA comprometido alterando valores sem passar pela API).


Prevenção de IDOR

Toda query inclui user_id = :uid:

sql
SELECT * FROM vault_entries WHERE user_id = :uid AND key_name = :key

O usuário 200 consultando a chave private-key do usuário 100 recebe 404 — idêntico a "não encontrado", impedindo enumeração de quais chaves existem para outros usuários.

Os endpoints admin nunca retornam value:

php
// Usuário vê seu próprio valor
public function toUserArray(): array
{
    return ['key' => ..., 'value' => $this->value, ...];
}

// Admin vê apenas metadados — sem valor
public function toAdminArray(): array
{
    return ['user_id' => ..., 'key' => ..., ...];
}

Validação de Chave

php
private const string KEY_PATTERN = '/\A[a-z0-9_-]{1,64}\z/';

As âncoras \A e \z impedem correspondências parciais. O conjunto de caracteres é mínimo: alfanumérico minúsculo, hífen, underscore. O comprimento é limitado {1,64} — sem amplificação de backtracking.

Isso rejeita:

  • Letras maiúsculas (UPPER_CASE)
  • Espaços ou caracteres especiais
  • Fragmentos de path traversal (../etc/passwd)
  • Strings injetáveis em SQL (' OR '1'='1)
  • String vazia ou strings > 64 chars

Validação de ID de Usuário

php
private function resolveUserId(ServerRequestInterface $request): ?int
{
    $raw = $request->getHeaderLine('X-User-Id');
    if ($raw === '' || !ctype_digit($raw) || strlen($raw) > 18) return null;
    $id = (int) $raw;
    return $id > 0 ? $id : null;
}
  • ctype_digit() rejeita números negativos (o sinal - não é um dígito)
  • strlen > 18 impede overflow de inteiro (PHP_INT_MAX tem 19 dígitos)
  • > 0 rejeita "0" como ID de usuário inválido

Padrão de Upsert

php
public function store(int $userId, string $key, string $value): string
{
    $existing = $this->findEntry($userId, $key);
    if ($existing !== null) {
        // UPDATE ...
        return 'updated';  // → 200
    }
    // INSERT ...
    return 'stored';  // → 201
}

Retorna 'stored' (201) na primeira escrita, 'updated' (200) na sobrescrita. O handler mapeia esses valores para códigos de status HTTP.


Resultados VULN-A~L

VerificaçãoTesteResultado
VULN-AInjeção SQL no parâmetro de chave / corpoPASS — validação da chave rejeita antes da query
VULN-BIDOR: usuário lê/exclui chave de outro usuárioPASS — 404 no acesso entre usuários
VULN-CLista retorna apenas próprias entradasPASS — WHERE com escopo de user_id
VULN-DForça bruta / bypass da chave adminPASS — hash_equals + fail-closed
VULN-EXSS no valorPASS — armazenado como-está, resposta JSON não é HTML
VULN-FIdempotência de upsert de chavePASS — última escrita vence, sem duplicatas
VULN-GPath traversal na chavePASS — padrão rejeita .. e barras
VULN-Huser-id negativo / zeroPASS — guarda ctype_digit + > 0
VULN-Iuser-id muito grande (overflow)PASS — guarda strlen > 18
VULN-JByte nulo no caminhoPASS — roteador / padrão rejeita
VULN-KChave muito longa no corpoPASS — validação 422
VULN-LSegredo HMAC vazio (sem pânico)PASS — HMAC determinístico com chave vazia, sem crash

Notas de Teste

  • AppFactory::create(?PDO, ?string adminKey, ?string hmacSecret) — todos injetáveis para testes unitários.
  • withParsedBody($body) é obrigatório em helpers de teste (Nyholm PSR-7 não analisa JSON automaticamente).
  • Testes IDOR: armazenar como usuário 100, tentar acesso como usuário 200 → deve obter 404.
  • Testes admin: verificar que a chave value está ausente em todo array de resposta.

Publicado sob a licença MIT.