Article initialement posté sur notnil.org, le blog perso de matthieu
Prélude
Depuis quelque temps, je développe sur mon temps libre un service d'authentification complet, avec l'objectif d'avoir une implémentation de nombreux mécanismes d'authentification : magic link, login/password, Oauth2, validation de compte, récupération de password, et le célèbre 2FA.
J'avoue que j'utilisais tous les jours Google Authenticator, sans avoir pensé à regarder le mécanisme “sous le capot”. Cet article est donc la vulgarisation de ce que j'ai appris.
2 Factor Access
Tout d'abord, rapide rappel de ce qu'est le 2FA, en français, l'authentification à 2 facteurs. Celui-ci est une méthode d'authentification forte qui demande à l'utilisateur d'apporter 2 preuves d'identité. Vous vous connectez sur un site avec votre login/password et vous devez saisir un code dont vous êtes seul à avoir connaissance.
One Time Password (OTP)
L'OTP est un password qui, comme son nom l'indique, n'est utilisable qu'une seule fois : une action de connexion. A l'inverse de votre password de connexion statique, qui ne change pas tant que VOUS ne le changez, l'OTP est dynamique, c'est-à-dire qu'il va changer à chaque action.
L'OTP peut être généré de différentes façons :
- par l'utilisation du temps et une clef secrète partagée entre le client et le serveur (c'est le T-OTP que nous allons creuser par la suite)
- par une chaîne de hash : un password est généré à partir du précédent
- par un compteur ou un challenge mathématique
La mise en place pour l'utilisateur peut être imaginée de différentes façons :
- une application comme Google Authenticator ou Authy
- de petits devices électroniques dotés d'un afficheur qui fournissent des codes d'authentification
- un SMS reçu sur le mobile de l'utilisateur
- et même du papier
HMAC-OTP
H-OTP a été décrit dans la RFC4226.
Un algorithme de génération de code unique est utilisé symétriquement par le client et le serveur pour générer un code à chaque demande de connexion. Le serveur compare le code fourni par le client avec celui qu'il a lui-même généré, et autorise la connexion s'il y a une correspondance des 2 codes.
Le code est généré par un algorithme de hachage prédéfini (SHA-1, SHA-256 ou SHA-512) sur la clé secrète ainsi qu'un facteur dynamique commun au serveur et au client, comme par exemple un compteur de connexion. Le résultat est tronqué pour pouvoir extraire le code HTOP, celui-ci pouvant faire une longueur de 6 digits (le plus courant) ou plus.
Time based-OTP
Le Time based One Time Password est un dérivé du H-OTP, et décrit dans la RFC6238.
Ici le facteur dynamique T est le nombre d'intervalles de temps X (généralement 30s) écoulés depuis le temps UNIX, soit le 01 janvier 1970 UTC avec
T = (time.Now() - T0)/X
.Tous les intervalles de temps X, le code HMAC changera, et quelque soit l'appareil, la valeur de T sera la même.
A l'activation du 2FA T-OTP, le serveur génère pour chaque utilisateur une clef secrète. Le client a besoin de quelques informations pour pouvoir générer des codes identiques à ceux du serveur :
- l'intervalle T
- l'algorithme utilisé
- le compte de l'utilisateur
- l'émetteur
- la clé secrète
Ces informations seront transmises au client de connexion via un QR code ou une URI. Le QR code ci-dessous comprend les mêmes informations que l'URI suivante :
otpauth://totp/demoT-OTP:debbie.harry@coddity.com?algorithm=SHA1&digits=6&issuer=demoT-OTP&period=30&secret=MEQGI2LSOR4SA3DJOR2GKIDTMVRXEZLU
Code de démo
J'ai développé un petit programme en Go pour que vous puissiez vous amuser avec le T-OTP, celui-ci est disponible ici. Ce code utilise un package permettant de générer des HOTP et TOTP et de vérifier les codes transmis par un client.
Les paramètres utilisés sont les suivants (dans le dur, s'il vous plait) :
- une clef secrète
a dirty little secret
- un intervalle de temps X fixé à 30 secondes
- l'utilisation de SHA-1 pour l'algorithme de hachage
- une longueur de 6 digits pour le code HMAC
- un account fixe
debbie.harry@coddity.com
- un issuer
demoT-OTP
Pour l'utiliser, rien de plus simple. Go installé sur votre machine, il vous suffit de build et de lancer le code avec l'argument
generate
pour générer l'utilisateur, run
pour lancer la génération des codes et verify <TOTP code>
pour vérifier un code fourni par le client.Ceux qui suivent auront compris que toute personne exécutant la commande
generate
va générer le même utilisateur (debbie.harry@coddity.com
) avec la même clé secrète a dirty litte secret
. La conséquence est que les codes générés sur chaque application de type Authenticator seront identiques et changeront toutes les 30 secondes, à moins de changer la clé secrète dans le code.package main
import (
"encoding/base32"
"fmt"
"log"
"os"
"time"
"github.com/gosuri/uilive"
"github.com/jltorresm/otpgo"
"github.com/jltorresm/otpgo/config"
)
var account *otpgo.TOTP
func newOTP() *otpgo.TOTP {
secretString := []byte("a dirty litte secret")
secret := base32.StdEncoding.EncodeToString(secretString)
return &otpgo.TOTP{
Key: secret,
Period: 30,
Delay: 2,
Algorithm: config.HmacSHA1,
Length: 6,
}
}
func generateQRCode() {
keyUri := account.KeyUri("debbie.harry@coddity.com", "demoT-OTP")
uri := keyUri.String()
qrCode, err := keyUri.QRCode()
if err != nil {
log.Fatal(err)
}
fmt.Printf("************URI**************\n%s\n*****************************\n\n\n************QRCode**************\n%s\n*****************************", uri, qrCode)
}
func run() {
writer := uilive.New()
writer.Start()
for {
token, err := account.Generate()
if err != nil {
log.Fatalf(err.Error())
}
fmt.Fprintf(writer, "code : %s\n", token)
time.Sleep(500 * time.Millisecond)
}
writer.Stop()
}
func verify(code string) {
access, err := account.Validate(code)
if err != nil {
log.Fatalf(err.Error())
}
if access {
fmt.Println("login in!")
} else {
fmt.Println("access denied")
}
}
func main() {
account = newOTP()
if len(os.Args) == 1 || len(os.Args) > 3 {
fmt.Println("Usage : generate / run / verify <CODE_2_VERIFY>")
os.Exit(1)
}
switch os.Args[1] {
case "generate":
generateQRCode()
case "run":
run()
case "verify":
if len(os.Args) != 3 {
fmt.Println("need code to verify")
os.Exit(1)
}
verify(os.Args[2])
default:
fmt.Println("Usage : generate / run / verify <CODE_2_VERIFY>")
os.Exit(1)
}
}
Précisions
👉 La durée habituelle de 30 secondes de validité est un compromis entre la sécurité et la facilité d'utilisation : suffisamment longue pour une saisie manuelle et suffisamment courte pour réduire le temps d'attaque si un tiers avait connaissance de l'OTP. Par ailleurs, un temps plus long de saisie d'un OTP bloquerait l'accès à l'utilisateur ayant fait une erreur de saisie. Seriez-vous prêt(e) à avoir des fenêtres de saisie de code de 3 min et potentiellement attendre la même durée en cas d'erreur ? J'en doute fort 🤓
👉 La RFC envisage les problèmes liés à la désynchronisation temporelle entre le client et le serveur valideur en recommandant d'accepter les codes des intervalles de temps précédents (ou suivants) selon un pas pré-défini P : pour une valeur de T le serveur va aussi vérifier les codes correspondants au range [T-P; T+P]. De cette façon, en cas de désynchronisation, l'utilisateur pourra tout même être validé.
👉 Le serveur doit stocker la clef secrète lié à un utilisateur, contrairement aux mot de passe dont seul le hash est sauvegardé, possiblement une faille de sécurité.
👉 Il n'y a pas d'appels au réseau, le code peut être généré en hors ligne par les applications d'authentification, mais j'avoue ne pas avoir vérifié si, par exemple, Google Authenticator logue les applications que vous utilisez et les envoie quelque part.
👉 La perte du device contenant l'application et la clé secrête liée au compte est un problème, à moins d'avoir conservé l'URI/QRcode quelque part ou d'avoir utilisé un deuxième device lors de l'enregistrement. Généralement, une clef de récupération est fournie lors de l'activation du 2FA basé sur une suite de mots. Authy propose un système de backup pour palier à ce problème, mais si Authy se fait hacker... bref, vous avez compris.