Gestion des clés API
Référence FT : FT266 (
NENE2-FT/apikeylog) — Cycle de vie des clés API : génération, stockage du hash SHA-256, recherche par préfixe, application du scope, rotation
Ce guide couvre l'implémentation de la gestion des clés API dans les applications NENE2 : génération de clé, stockage sécurisé, autorisation basée sur le scope, révocation et rotation.
Principes de conception fondamentaux
- Ne jamais stocker les clés brutes — seulement les hashes SHA-256 dans la base de données.
- Retourner la clé brute une fois — uniquement à la création, jamais ensuite.
- Recherche par préfixe, vérification par hash — le préfixe réduit la requête DB ; hash_equals() fait l'authentification réelle.
- Hiérarchie de scope — admin ⊃ write ⊃ read ; vérifié par endpoint.
- Rotation sécurisée — créer la nouvelle clé avant de révoquer l'ancienne pour éviter le blocage.
Format de la clé
nk_Vf3aB2cX9dJkQmHpNrTsUvWxYzAeBfCg
^ ^----- 43 chars de base64url(32 octets aléatoires) -----^
|
préfixe de type (identifiable dans les logs)random_bytes(32) donne 256 bits d'entropie. C'est informatiquement impossible à forcer par brute-force indépendamment de la vitesse de hachage, donc SHA-256 (rapide, monopurpose) est approprié — contrairement aux mots de passe, les clés API ne sont pas attaquables par dictionnaire.
Schéma
sql
CREATE TABLE IF NOT EXISTS api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner_id INTEGER NOT NULL,
prefix TEXT NOT NULL, -- 16 premiers chars de la clé brute (index de recherche)
key_hash TEXT NOT NULL UNIQUE,
scope TEXT NOT NULL DEFAULT 'read',
description TEXT NOT NULL DEFAULT '',
expires_at TEXT,
revoked_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);La colonne prefix stocke les 16 premiers caractères de la clé brute (pas le préfixe de type nk). Cela donne ~78 bits de différenciation, rendant chaque préfixe effectivement unique et permettant une recherche d'index O(1).
Critique : N'utilisez PAS le préfixe de type (nk) comme préfixe de recherche DB. Toutes les clés partagent le même préfixe de type, donc WHERE prefix = 'nk' scannerait la table entière — recherche O(n) et canal de timing proportionnel au nombre de clés.
Génération de clé
php
final class ApiKeyGenerator
{
private const string PREFIX = 'nk';
private const int BYTES = 32;
public function generate(): string
{
$raw = random_bytes(self::BYTES);
return self::PREFIX . '_' . rtrim(strtr(base64_encode($raw), '+/', '-_'), '=');
}
public function hash(string $rawKey): string
{
return hash('sha256', $rawKey);
}
public function extractPrefix(string $rawKey): string
{
// 16 premiers chars de la clé complète — unique par clé, sûr à indexer
return substr($rawKey, 0, 16);
}
public function verify(string $rawKey, string $storedHash): bool
{
return hash_equals($storedHash, $this->hash($rawKey));
}
}hash_equals() est obligatoire. Utiliser === ou == pour la comparaison de hash divulgue des informations de timing : une chaîne hexadécimale de 64 caractères comparée avec === sort à la première incompatibilité, révélant combien de caractères initiaux correspondent.
Flux d'authentification
php
public function authenticate(string $rawKey, string $now): ?ApiKey
{
$prefix = $this->generator->extractPrefix($rawKey);
$rows = $this->executor->fetchAll(
'SELECT * FROM api_keys WHERE prefix = ?',
[$prefix],
);
foreach ($rows as $row) {
$key = $this->hydrate($row);
if ($this->generator->verify($rawKey, $key->keyHash) && $key->isActive($now)) {
return $key;
}
}
return null;
}L'approche en deux étapes :
- Recherche d'index par préfixe (requête DB rapide)
- Vérification
hash_equals()contre le hash stocké
Retourner le même null et 401 pour tous les cas d'échec (non trouvé, mauvais hash, expiré, révoqué) — les appelants ne doivent pas les distinguer.
Hiérarchie de scope
php
enum ApiKeyScope: string
{
case Read = 'read';
case Write = 'write';
case Admin = 'admin';
public function allows(self $required): bool
{
return match ($required) {
self::Read => true,
self::Write => $this === self::Write || $this === self::Admin,
self::Admin => $this === self::Admin,
};
}
}Appliquer le scope au niveau de l'endpoint :
php
private function requireScope(ServerRequestInterface $request, ApiKeyScope $required): ApiKey|ResponseInterface
{
$rawKey = $request->getHeaderLine('X-Api-Key');
if ($rawKey === '') {
return $this->problems->create($request, 'unauthorized', 'Missing X-Api-Key header.', 401, '');
}
$key = $this->repo->authenticate($rawKey, $now);
if ($key === null) {
return $this->problems->create($request, 'unauthorized', 'Invalid or expired API key.', 401, '');
}
if (!$key->scope->allows($required)) {
return $this->problems->create($request, 'forbidden', 'Insufficient scope.', 403, '');
}
return $key;
}Retourner 401 pour les non-authentifiés, 403 pour les authentifiés avec scope insuffisant — ne jamais divulguer si la clé existe.
Filtrage de la réponse
La méthode toArray() sur ApiKey ne doit pas inclure key_hash. La clé brute n'est disponible que via ApiKeyCreateResult::toArray() immédiatement après la création.
php
// ApiKey::toArray() — sûr à retourner depuis n'importe quel endpoint
public function toArray(): array
{
return [
'id', 'owner_id', 'prefix', 'scope', 'description',
'expires_at', 'revoked_at', 'created_at', 'updated_at',
// key_hash est intentionnellement absent
];
}
// ApiKeyCreateResult::toArray() — endpoint de création uniquement
public function toArray(): array
{
return array_merge($this->key->toArray(), ['key' => $this->rawKey]);
}Rotation de clé — ordre sécurisé
Toujours créer la nouvelle clé avant de révoquer l'ancienne.
php
public function rotate(int $oldId, int $ownerId, string $now): ?ApiKeyCreateResult
{
$old = $this->findById($oldId);
if ($old === null || $old->ownerId !== $ownerId || $old->isRevoked()) {
return null;
}
// Créer d'abord — si cela échoue, l'ancienne clé reste active (pas de blocage)
$result = $this->create($ownerId, $old->scope, $old->description, $now, $old->expiresAt);
// Révoquer ensuite — si cela échoue, les deux clés existent temporairement (récupérable via liste)
$this->executor->execute(
'UPDATE api_keys SET revoked_at = ?, updated_at = ? WHERE id = ?',
[$now, $now, $oldId],
);
return $result;
}Révoquer-puis-créer est dangereux : si CREATE échoue après REVOKE, le propriétaire est définitivement bloqué. L'inverse (créer-puis-révoquer) signifie que le pire cas est deux clés actives temporairement — observable et récupérable.
Expiration
Stocker expires_at comme chaîne datetime ISO. Vérifier dans isActive() :
php
public function isActive(string $now): bool
{
return !$this->isRevoked() && !$this->isExpired($now);
}
public function isExpired(string $now): bool
{
return $this->expiresAt !== null && $this->expiresAt < $now;
}Le flux d'authentification passe $now comme paramètre, rendant la logique testable avec des timestamps fixes.
Ce qu'il ne faut PAS faire
| Anti-pattern | Risque |
|---|---|
| Stocker la clé brute en DB | Exposition complète en cas de brèche DB |
Utiliser === pour la comparaison de hash | L'attaque par timing révèle la longueur du préfixe de hash |
Utiliser le préfixe de type (nk) comme index de recherche DB | Scan de table O(n) ; canal de timing |
Retourner key_hash dans les réponses de liste/détail | Attaque par dictionnaire hors ligne sur les hashes |
| Révoquer l'ancienne clé avant de créer la nouvelle lors de la rotation | Blocage du propriétaire en cas d'erreur DB |
| Retourner des erreurs différentes pour "clé non trouvée" vs "clé expirée" | Oracle pour l'existence de clé |
Logger l'en-tête X-Api-Key | La clé fuit dans le stockage de logs |