Comprendre le fonctionnement du 2FA TOTP dans gitrust

Ce que vous allez comprendre

  • Décrire le flux cryptographique TOTP (RFC 6238) de la génération du secret à la vérification du code.
  • Analyser les choix de sécurité : chiffrement du secret, codes de secours, challenges temporaires.
  • Évaluer pourquoi le 2FA ne protège pas contre le vol de session et comment gitrust atténue ce risque.

Le problème concret

Alice utilise un mot de passe fort. Mais si sa base de mots de passe est compromise, ou si un attaquant intercepte son mot de passe (phishing), il peut se connecter à son compte gitrust et pousser du code malveillant. Un second facteur garantit que la possession du mot de passe seul ne suffit pas.

L'analogie

TOTP ressemble à un générateur de codes de distributeur bancaire qui change toutes les 30 secondes. La banque et le distributeur partagent un secret commun (la clé de votre carte). À partir de ce secret et de l'heure actuelle, les deux parties calculent indépendamment le même code. Personne n'a besoin de transmettre le code sur le réseau.

La différence avec un SMS OTP : le secret n'est jamais transmis après la configuration initiale. Un attaquant qui intercepte votre réseau ne voit jamais le code à venir.

Le modèle

Génération du secret (RFC 6238)

sequenceDiagram
    participant User as Utilisateur
    participant Server as Serveur gitrust
    participant App as App authenticator

    User->>Server: POST /settings/security/2fa/setup
    Server->>Server: Générer secret aléatoire 20 octets (CSPRNG)
    Server->>Server: Chiffrer AES-256-GCM → stocker dans user_totp
    Server-->>User: { secret_base32, otpauth_uri, qr_code_data_uri }
    User->>App: Scanner le QR code
    App->>App: Stocker le secret localement
    User->>Server: POST /settings/security/2fa/verify { code: "123456" }
    Server->>Server: Déchiffrer secret, calculer TOTP, vérifier fenêtre ±1
    Server-->>User: { backup_codes: [...] } — activé

L'URI OTPAuth encode tout ce qu'une app authenticator a besoin :

otpauth://totp/gitrust:alice?secret=JBSWY3DPEHPK3PXP&issuer=gitrust&algorithm=SHA1&digits=6&period=30

Calcul du code TOTP

TOTP est HOTP (RFC 4226) avec un compteur basé sur le temps :

counter = floor(unix_timestamp / 30)
hmac    = HMAC-SHA1(secret, counter_as_8_bytes_big_endian)
offset  = hmac[19] & 0x0f
code    = (hmac[offset..offset+4] & 0x7fffffff) % 1_000_000

Le code est valide pendant 30 secondes. Gitrust accepte une fenêtre de tolérance ±1 période (90 secondes totales) pour compenser les légères dérives d'horloge entre l'appareil de l'utilisateur et le serveur.

Flux de login avec 2FA activé

sequenceDiagram
    participant C as Client
    participant S as Serveur

    C->>S: POST /api/v1/auth/login { username, password }
    S->>S: verify_credentials() → OK
    S->>S: is_totp_enabled(user_id) → true
    S->>S: create_challenge(user_id) → challenge_token (5 min, max 5 tentatives)
    S-->>C: { requires_2fa: true, challenge_token: "abc..." }
    C->>S: POST /api/v1/auth/2fa/verify { challenge_token, code }
    S->>S: validate_challenge() → non expiré, < 5 tentatives
    S->>S: verify_code() ou verify_backup_code()
    S->>S: consume_challenge()
    S-->>C: { access_token, refresh_token, user }

Le challenge expire après 5 minutes. Maximum 5 tentatives par challenge. Un challenge échoué 5 fois force une nouvelle authentification complète.

Stockage sécurisé du secret

Le secret TOTP n'est jamais stocké en clair en base de données. Il est chiffré avec AES-256-GCM via CryptoService avant insertion dans user_totp.encrypted_secret.

// À l'activation
let encrypted = CryptoService::encrypt(&secret_base32)?;
// → stocké : "enc:base64(nonce||ciphertext)"

// À la vérification
let secret = CryptoService::decrypt(&user_totp.encrypted_secret)?;

La clé de chiffrement est ENCRYPTION_KEY (32 octets, variable d'environnement). Si elle est absente, le TOTP ne peut pas être activé.

Codes de secours

À l'activation du 2FA, 10 codes de secours de 8 caractères alphanumériques sont générés. Ils sont stockés hachés bcrypt dans user_totp.backup_codes_json (JSON chiffré).

Chaque code est à usage unique : une fois utilisé, il est marqué consommé. L'utilisateur peut régénérer un nouveau jeu de 10 codes depuis /settings/security.

Format : XXXXXXXX (ex : A3B7K2M9) — reconnaissable par sa longueur (8 chars vs 6 chiffres pour TOTP).

La détection est automatique dans verify_backup_code : si le code contient des lettres ou fait 8 caractères, c'est un code de secours ; sinon c'est un code TOTP.

Force 2FA par l'administrateur

Un administrateur peut forcer le 2FA sur toute l'instance via AppSettingsService::set_setting("force_2fa_enabled", "true"). Quand ce flag est actif :

  • Les utilisateurs sans 2FA sont redirigés vers la page de configuration à chaque login.
  • Les endpoints API retournent 403 avec {"error":"totp_required"} tant que le 2FA n'est pas configuré.

Alternatives et compromis

Pourquoi TOTP et pas WebAuthn/passkeys ? WebAuthn offre une protection supérieure contre le phishing (le domaine est lié cryptographiquement). Gitrust prévoit de l'ajouter en phase future. TOTP a été choisi pour sa compatibilité universelle (toutes les apps authenticator, hors connexion, pas de dépendance matérielle).

Pourquoi pas SMS OTP ? Les SMS OTP sont vulnérables au SIM swapping et à l'interception SS7. Gitrust ne les implémente pas.

La fenêtre de ±1 période réduit-elle la sécurité ? Marginalement. Elle augmente la fenêtre d'attaque de 30 à 90 secondes, mais un code TOTP reste à 6 chiffres (1/1 000 000 de probabilité de deviner), et les 5 tentatives max par challenge rendent le brute-force infaisable.

Vérifier votre compréhension

  1. Un utilisateur est dans un fuseau horaire décalé de 45 secondes par rapport au serveur. Sa tentative de connexion TOTP échoue systématiquement. Pourquoi, et comment diagnostiquer ?

  2. L'ENCRYPTION_KEY du serveur change (rotation). Que se passe-t-il pour les utilisateurs ayant déjà activé le 2FA ? Quelle procédure de migration faudrait-il mettre en place ?

Pour aller plus loin