Skip to content

Autenticação por PIN e Lockout

Referência FT: FT252 (NENE2-FT/pinverifylog) — Verificação de PIN com Lockout ATK: FT252 — teste de ataque com mentalidade de cracker (ATK-01 a ATK-12)

Guia de implementação de prevenção de força bruta para PIN de 6 dígitos, proteção contra ataques de timing e desbloqueio por administrador. Explica armazenamento de hash HMAC-SHA256, comparação em tempo constante e lockout por número de tentativas.

Segurança comprovada em FT192: VULN-A~L todos Pass / ATK-01~12 todos Pass.

Visão Geral

  • Admin cria o PIN (armazenamento de hash HMAC-SHA256 — texto puro não armazenado)
  • Usuário verifica o PIN (lockout após atingir o limite de falhas)
  • Admin desbloqueia
  • Histórico de tentativas registrado como log de auditoria

Endpoints

MétodoCaminhoAuthDescrição
POST/pinsX-Admin-KeyCriar PIN
POST/pins/{id}/verifyVerificar PIN
GET/pins/{id}X-Admin-KeyVerificar status (tentativas restantes, prazo de lock)
POST/pins/{id}/unlockX-Admin-KeyDesbloquear
DELETE/pins/{id}X-Admin-KeyExcluir PIN

Design do Banco de Dados

sql
CREATE TABLE IF NOT EXISTS pins (
    id           INTEGER PRIMARY KEY AUTOINCREMENT,
    label        TEXT    NOT NULL,
    pin_hash     TEXT    NOT NULL,        -- HMAC-SHA256(pin, secret)
    attempts     INTEGER NOT NULL DEFAULT 0,
    max_attempts INTEGER NOT NULL DEFAULT 5,
    locked_until TEXT,                    -- ISO 8601 UTC, NULL = desbloqueado
    created_at   TEXT    NOT NULL,
    updated_at   TEXT    NOT NULL
);

CREATE TABLE IF NOT EXISTS pin_attempts (
    id           INTEGER PRIMARY KEY AUTOINCREMENT,
    pin_id       INTEGER NOT NULL,
    success      INTEGER NOT NULL DEFAULT 0,
    attempted_at TEXT    NOT NULL
);

locked_until é armazenado como string ISO 8601 e o estado de lock é determinado por comparação de strings com o horário atual ($lockedUntil > $now). Sem custo de conversão.

Hash HMAC-SHA256 do PIN

O PIN não é armazenado em texto puro; é hashado com HMAC-SHA256. Misturar a chave secreta do servidor ($hmacSecret) dificulta a força bruta em caso de vazamento do banco:

php
private function hashPin(string $pin): string
{
    return hash_hmac('sha256', $pin, $this->hmacSecret);
}

Comparação em Tempo Constante (VULN-E / ATK-02)

=== termina antecipadamente durante a comparação de bytes, permitindo que ataques de timing adivinhem o hash correto. hash_equals() sempre compara todos os bytes:

php
// ❌ Perigoso: adivinhável por ataque de timing
if ($stored === $provided) { ... }

// ✅ Seguro: comparação em tempo constante
$provided = $this->hashPin($pin);
$success  = hash_equals($pin1->pinHash, $provided);

Prevenção de Força Bruta (ATK-01)

Quando o número de falhas atinge max_attempts, define locked_until e rejeita todas as tentativas subsequentes (incluindo PIN correto) com 423:

php
public function verify(int $id, string $pin): string
{
    $now  = $this->now();
    $pin1 = $this->findById($id);

    // 1. Verificar lock antes da tentativa
    if ($pin1->isLocked($now)) {
        return 'locked'; // → 423
    }

    // 2. Comparação em tempo constante
    $provided = $this->hashPin($pin);
    $success  = hash_equals($pin1->pinHash, $provided);

    if ($success) {
        // Redefinir contagem de tentativas no sucesso
        $this->resetAttempts($id, $now);
        return 'success'; // → 200
    }

    // 3. Falha: incrementar → lock no limite
    $newAttempts = $pin1->attempts + 1;
    $lockedUntil = null;

    if ($newAttempts >= $pin1->maxAttempts) {
        $lockedUntil = $this->lockUntil($now); // 5 minutos depois
    }

    $this->incrementAttempts($id, $newAttempts, $lockedUntil, $now);

    return $newAttempts >= $pin1->maxAttempts ? 'locked' : 'wrong'; // → 423 ou 401
}

Importante: A verificação de lock deve ocorrer antes da tentativa. Se verificada depois, a última tentativa que atinge o estado de lock pode passar.

Chave de Admin fail-closed (VULN-H / ATK-03)

php
private function isAdmin(ServerRequestInterface $request): bool
{
    if ($this->adminKey === '') {
        return false; // adminKey vazia sempre recusada
    }

    $provided = $request->getHeaderLine('X-Admin-Key');

    return $provided !== '' && hash_equals($this->adminKey, $provided);
}

Se adminKey for uma string vazia, retorna false incondicionalmente (evita tornar o admin aberto quando a variável de ambiente não está definida).

Validação de ID (VULN-A / ATK-07)

php
private function resolveId(ServerRequestInterface $request): ?int
{
    $raw = Router::param($request, 'id');

    if ($raw === null || !ctype_digit($raw) || strlen($raw) > 18) {
        return null; // → 422
    }

    $id = (int) $raw;

    return $id > 0 ? $id : null;
}

strlen($raw) > 18 previne overflow de inteiro de 64 bits (PHP_INT_MAX tem 19 dígitos, mas com margem de segurança).

Validação do PIN (VULN-D)

Use ctype_digit(). Regex (/^[0-9]+$/) pode resultar em ReDoS com complexidade O(n²), enquanto ctype_digit() é O(n) e seguro:

php
private function validatePin(mixed $pin): ?string
{
    if (!is_string($pin)) {
        return 'pin must be a string.'; // VULN-G: prevenção de confusão de tipo
    }

    $len = strlen($pin);
    if ($len < self::MIN_PIN_LEN || $len > self::MAX_PIN_LEN) {
        return 'pin must be between 4 and 8 digits.';
    }

    if (!ctype_digit($pin)) { // O(n), sem ReDoS
        return 'pin must contain only digits.';
    }

    return null;
}

Design da Resposta

Nunca incluir o hash do PIN na resposta. O mesmo vale para respostas de admin:

php
public function toAdminArray(): array
{
    return [
        'id'                 => $this->id,
        'label'              => $this->label,
        'attempts'           => $this->attempts,
        'max_attempts'       => $this->maxAttempts,
        'locked_until'       => $this->lockedUntil,
        'remaining_attempts' => $this->remainingAttempts(),
        'created_at'         => $this->createdAt,
        // pin_hash não incluído
        // updated_at não incluído como informação interna
    ];
}

Exemplos de Resposta

json
// POST /pins (201)
{
    "pin": {
        "id": 1,
        "label": "vault",
        "attempts": 0,
        "max_attempts": 5,
        "locked_until": null,
        "remaining_attempts": 5,
        "created_at": "2026-05-26T10:00:00+00:00"
    }
}

// POST /pins/1/verify — sucesso (200)
{ "success": true, "locked": false }

// POST /pins/1/verify — falha (401)
{ "success": false, "locked": false }

// POST /pins/1/verify — bloqueado (423)
{ "success": false, "locked": true, "error": "PIN is locked due to too many failed attempts." }

// POST /pins/1/unlock (200)
{ "unlocked": true }

Pontos de Segurança (VULN-A~L / ATK-01~12 todos Pass)

AmeaçaCategoriaContramedida
Força brutaATK-01Limite max_attempts → lock por 5 min com locked_until
Ataque de timing (PIN)ATK-02 / VULN-EComparação em tempo constante com hash_equals()
Bypass de chave adminATK-03 / VULN-HadminKey = '' → false (fail-closed)
Enumeração de IDATK-04ID inexistente retorna 404 (sem vazamento de informação)
Injeção SQL (valor PIN)ATK-05 / VULN-Bctype_digit passa apenas dígitos → PDO prepared statement
Injeção SQL (ID)ATK-06 / VULN-BGuarda ctype_digit + strlen > 18 → 422
Overflow de inteiroATK-07 / VULN-A / VULN-JGuarda strlen > 18
Bypass de lockoutATK-08Verificação de lock antes da tentativa, persistência no banco
Re-ataque após desbloqueioATK-09Após unlock, attempts = 0 reset (comportamento normal)
Injeção no corpoATK-10 / VULN-IAceitar apenas campos explícitos
Timing da chave adminATK-11Comparação em tempo constante com hash_equals()
Label BIDI/UnicodeATK-12 / VULN-LVerificação de comprimento com mb_strlen, armazenamento seguro com PDO
ReDoSVULN-Dctype_digit() O(n), sem regex
Confusão de tipoVULN-GVerificação !is_string($pin)
Overflow de max_attemptsVULN-FVerificação de intervalo 1~20
SSRFVULN-KSem comunicação HTTP externa (N/A)
Path traversalVULN-CSem operações de arquivo (N/A)

Guias Relacionados

Publicado sob a licença MIT.