Chiffrer ses secrets en Go : du bricolage maison aux bonnes primitives
Un SaaS classique accumule des secrets pour le compte de ses utilisateurs : clé API Stripe, secret webhook, parfois des tokens OAuth. Ces données vivent en base de données. Quand cette base est compromise — injection SQL non détectée, snapshot S3 mal configuré, accès jamais révoqué après un départ — les credentials de l’ensemble de tes clients partent avec.
type Account struct {
UserID int
StripeAPIKey string // ⚠️ Sensible
WebhookSecret string // ⚠️ Sensible
}
La réponse évidente : chiffrer ces champs avant de les écrire en base. La question qui suit immédiatement : comment ? Et là, la plupart des développeurs font des choix qui semblent raisonnables mais qui ne le sont pas vraiment.
La solution naïve : AES-GCM avec SHA256
Le premier réflexe, souvent inspiré d’un snippet Stack Overflow ou d’un article de blog, ressemble à ça :
package main
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"io"
)
func deriveKey(passphrase string) []byte {
hash := sha256.Sum256([]byte(passphrase))
return hash[:]
}
func encrypt(passphrase, plaintext string) (string, error) {
key := deriveKey(passphrase)
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
return hex.EncodeToString(ciphertext), nil
}
func decrypt(passphrase, ciphertextHex string) (string, error) {
key := deriveKey(passphrase)
ciphertext, err := hex.DecodeString(ciphertextHex)
if err != nil {
return "", err
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonceSize := gcm.NonceSize()
nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return "", err
}
return string(plaintext), nil
}
AES-GCM est un bon choix de primitive symétrique — authentifié, éprouvé, bien supporté en Go. Le problème n’est pas là. Le problème, c’est tout ce qui entoure.
Problème 1 : SHA256 n’est pas une KDF
SHA256 est une fonction de hachage. Son objectif de conception est d’être le plus rapide possible tout en étant résistant aux collisions. Pour valider un certificat ou hasher un fichier de 4 GB, c’est exactement ce qu’on veut. Pour dériver une clé cryptographique depuis une passphrase humaine, c’est un désastre.
Une RTX 4090 calcule environ 10 milliards de SHA256 par seconde. Un mot de passe comme P@ssw0rd123 tombe en quelques minutes en brute force.
Une Key Derivation Function (KDF) digne de ce nom est intentionnellement lente et coûteuse en ressources. SHA256 n’a rien de tout ça — il a été optimisé dans le sens exactement opposé.
Problème 2 : Absence de sel
deriveKey("my-super-password") retournera toujours la même chose. C’est déterministe par construction.
Conséquence directe : deux comptes qui utilisent la même passphrase pour chiffrer leurs données produisent la même clé AES. Un attaquant qui dump ta base peut immédiatement identifier les doublons. Il lui suffit de craquer une entrée pour déchiffrer toutes les entrées identiques — en une seule passe. Sans sel, les rainbow tables (tables précalculées qui mappent des hashs SHA256 courants vers leurs entrées d’origine) deviennent aussi directement exploitables.
Problème 3 : Pas de versioning dans le payload
Le format produit par ce code est nonce || ciphertext, encodé en hex. Aucune métadonnée. Aucune indication de l’algorithme de dérivation, des paramètres utilisés, ou de la version du format.
Dans six mois, tu veux migrer vers une KDF correcte. Comment tu distingues tes anciens enregistrements (SHA256) des nouveaux (Argon2id) ? Tu ne peux pas — sauf à tenir un registre externe ou à migrer tous tes enregistrements à l’aveugle. Ce genre de dette technique se paie toujours avec intérêts.
Problème 4 : Gestion mémoire
Accessoire mais réel : la passphrase et la clé dérivée restent en mémoire sous forme de strings jusqu’au prochain GC, potentiellement plus longtemps. Un crash dump, une vulnérabilité heap, ou un coredump activé par inadvertance les exposent. Ce n’est pas le vecteur le plus probable, mais sur des données critiques, ça compte.
La chaîne d’attaque en cas de fuite
sequenceDiagram
participant A as Attaquant
participant DB as Base de données (leakée)
participant GPU as GPU Farm
A->>DB: Récupère les champs chiffrés
DB-->>A: Liste des ciphertexts
A->>A: Détecte les doublons<br/>(même passphrase = même clé SHA256)
A->>GPU: Lance brute force SHA256<br/>(~10B essais/sec sur RTX 4090)
GPU-->>A: Passphrase trouvée en quelques heures
A->>A: Déchiffre TOUS les enregistrements identiques
La rapidité de SHA256 transforme une fuite de DB en compromission totale, pas partielle.
cryptio : les bonnes primitives, la même simplicité
cryptio est une lib Go que j’ai développée pour résoudre exactement ces problèmes sans avoir à réécrire de la cryptographie. L’API est délibérément minimaliste :
import "github.com/azrod/cryptio"
client, err := cryptio.New("ma-passphrase", cryptio.SecurityStandard, cryptio.ProfileBalanced)
if err != nil {
log.Fatal(err)
}
defer client.Wipe()
encrypted, err := client.Encrypt("sk_live_xxxxxxxxxxxx")
plaintext, err := client.Decrypt(encrypted)
Trois lignes pour chiffrer, trois pour déchiffrer. Mais sous le capot, chaque problème identifié ci-dessus est traité.
Argon2id comme KDF
Argon2id a remporté le Password Hashing Competition en 2015. Il a été conçu pour être coûteux en temps et en mémoire, ce qui le rend résistant aux accélérations GPU et ASIC. Avec le profil SecurityStandard de cryptio :
// Paramètres internes de SecurityStandard :
// ArgonTime: 2 (itérations)
// ArgonMem: 64 * 1024 (64 MiB)
// ArgonThreads: 1
// SaltSize: 16 bytes
// KeySize: 32 bytes
Sur le même GPU qui calculait 10 milliards de SHA256/seconde, Argon2id avec ces paramètres tombe à quelques dizaines d’essais par seconde au mieux. La différence n’est pas d’un facteur 2 ou 10 — elle est de plusieurs ordres de grandeur. C’est recommandé par OWASP et NIST pour exactement cette raison.
Sel aléatoire par chiffrement
Chaque appel à Encrypt() génère un sel cryptographiquement aléatoire de 16 bytes via crypto/rand. Ce sel est encodé directement dans le payload produit. Comparé à la solution naïve, la différence de structure est significative :
graph LR
n1["naïve — nonce 12B"] --> n2[ciphertext]
c1["cryptio — salt 16B"] --> c2["nonce 12B"] --> c3[ciphertext]
style n1 fill:#3d0000,color:#ff9999,stroke:#ff0000
style n2 fill:#3d0000,color:#ff9999,stroke:#ff0000
style c1 fill:#003d00,color:#99ff99,stroke:#00ff00
style c2 fill:#003d00,color:#99ff99,stroke:#00ff00
style c3 fill:#003d00,color:#99ff99,stroke:#00ff00
Deux appels avec la même passphrase et le même plaintext produisent deux ciphertexts différents. Les doublons deviennent indétectables, les rainbow tables deviennent inutiles.
Nettoyage mémoire
defer client.Wipe() zéroïse le slice de passphrase byte par byte. La clé dérivée par Argon2id est également zéroisée via un defer interne après chaque opération. Ce n’est pas une garantie absolue, mais c’est nettement mieux que de laisser traîner des strings en mémoire indéfiniment.
Choisir son niveau de sécurité
cryptio expose cinq niveaux préconfigurés. Le choix dépend du contexte et des contraintes de latence acceptables — données mesurées sur M1 Pro :
| Niveau | Temps | RAM | Cas d’usage |
|---|---|---|---|
UltraFast | ~30–48 ms | ~7–46 MB | Tests uniquement, jamais en prod |
Standard | ~81–209 ms | ~64 MB | APIs, SaaS — recommandé par défaut |
Medium | ~140–233 ms | ~128 MB | Entreprise, conformité RGPD/NIST |
High | ~388–485 ms | ~256 MB | Santé, finance, données critiques |
Extreme | >1,2 s | ~1 GB | Vaults, secrets d’infrastructure |
À partir de SecurityStandard, la lib enforce un minimum de mémoire — les profils Argon2 n’affectent alors que le CPU et le temps de calcul, pas la RAM.
En plus des niveaux, cinq profils permettent d’arbitrer entre RAM et CPU selon les contraintes de l’environnement :
// Serveur avec beaucoup de RAM disponible
client, _ := cryptio.New(pass, cryptio.SecurityStandard, cryptio.ProfileRAMHeavy)
// Environnement contraint en mémoire (Lambda, sidecar, etc.)
client, _ := cryptio.New(pass, cryptio.SecurityStandard, cryptio.ProfileCPUHeavy)
Intégration réelle : chiffrer les champs en base
Retour au scénario initial. Voici comment brancher cryptio sur l’Account struct de manière propre :
La passphrase ne doit jamais vivre dans le code source ou dans un .env committé. Elle doit être injectée via une variable d’environnement alimentée par un secret manager (K8s Secrets, AWS Secrets Manager, HashiCorp Vault).
var cryptoClient *cryptio.Client
func init() {
passphrase := os.Getenv("ENCRYPTION_KEY")
if passphrase == "" {
log.Fatal("ENCRYPTION_KEY non définie")
}
var err error
cryptoClient, err = cryptio.New(passphrase, cryptio.SecurityStandard, cryptio.ProfileBalanced)
if err != nil {
log.Fatalf("cryptio init: %v", err)
}
}
func (a *Account) Encrypt() error {
var err error
a.StripeAPIKey, err = cryptoClient.Encrypt(a.StripeAPIKey)
if err != nil {
return fmt.Errorf("chiffrement StripeAPIKey: %w", err)
}
a.WebhookSecret, err = cryptoClient.Encrypt(a.WebhookSecret)
if err != nil {
return fmt.Errorf("chiffrement WebhookSecret: %w", err)
}
return nil
}
func (a *Account) Decrypt() error {
var err error
a.StripeAPIKey, err = cryptoClient.Decrypt(a.StripeAPIKey)
if err != nil {
return fmt.Errorf("déchiffrement StripeAPIKey: %w", err)
}
a.WebhookSecret, err = cryptoClient.Decrypt(a.WebhookSecret)
if err != nil {
return fmt.Errorf("déchiffrement WebhookSecret: %w", err)
}
return nil
}
Le client est initialisé une seule fois au démarrage — pas question de re-dériver la clé Argon2id à chaque requête, ça prendrait 100–200 ms par opération. Avant d’écrire en base, account.Encrypt(). Après lecture, account.Decrypt(). Le reste du code ne voit que des strings lisibles.
Les limites à connaître
Le versioning des paramètres Argon2
Le payload cryptio encode le sel et le nonce, mais pas les paramètres Argon2 (niveau de sécurité, profil). Si tu passes de SecurityStandard à SecurityHigh dans six mois, tu ne pourras plus déchiffrer les anciens enregistrements avec le nouveau client — les paramètres de dérivation diffèrent, la clé produite sera différente.
La solution : stocker dans ta DB quel niveau et quel profil ont été utilisés pour chaque enregistrement, ou planifier une migration en batch avant de changer de paramètres.
Le chiffrement symétrique déplace le problème
Chiffrer ta DB est une bonne chose. Mais la passphrase doit bien vivre quelque part. Si elle est dans ton code ou dans un .env committé, tout s’effondre. Pour les workloads critiques, l’étape suivante est l’envelope encryption : ta passphrase elle-même est chiffrée par une clé maître gérée par un KMS (AWS KMS, GCP Cloud KMS, HashiCorp Vault). Tu ne stockes jamais la clé de chiffrement en clair nulle part.
sequenceDiagram
participant App as Application
participant SM as Secret Manager
participant KMS as KMS
participant DB as Base de données
Note over App,KMS: Démarrage
App->>SM: Récupère la passphrase chiffrée
SM-->>App: Passphrase chiffrée
App->>KMS: Déchiffre la passphrase
KMS-->>App: Passphrase en clair (mémoire uniquement)
App->>App: cryptio.New(passphrase, ...)
Note over App,DB: Runtime
App->>App: account.Encrypt()
App->>DB: INSERT — ciphertext stocké
Pour la majorité des SaaS, cryptio avec une passphrase injectée via secret manager est un niveau de protection parfaitement raisonnable. L’envelope encryption sur KMS, c’est la couche suivante — à envisager quand tu as de vraies contraintes de conformité (SOC 2, PCI-DSS, HIPAA).
Chiffrement applicatif vs. chiffrement au repos
cryptio chiffre les valeurs individuelles avant qu’elles n’atteignent la base. Ce n’est pas un substitut au chiffrement au repos de la base elle-même (AWS RDS encryption, pgcrypto, etc.). Les deux sont complémentaires :
graph LR
App[Application Go] --> Cryptio["cryptio<br/>Argon2id + AES-GCM"]
Cryptio -->|Ciphertext| DB[(Base de données)]
DB --> AtRest["Chiffrement au repos<br/>AWS RDS · pgcrypto"]
Threat1[Dump SQL / snapshot] -.->|bloqué| AtRest
Threat2[Accès DB compromis] -.->|bloqué| Cryptio
Le chiffrement au repos protège les fichiers physiques et les snapshots. Le chiffrement applicatif protège les données même quand un accès légitime à la DB est compromis — un ORM mal sécurisé, un read replica trop permissif, une console DB accessible sans MFA.