Chiffrer ses secrets en Go : du bricolage maison aux bonnes primitives

Article
🇬🇧 Cet article est aussi disponible en English

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 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 :

NiveauTempsRAMCas d’usage
UltraFast~30–48 ms~7–46 MBTests uniquement, jamais en prod
Standard~81–209 ms~64 MBAPIs, SaaS — recommandé par défaut
Medium~140–233 ms~128 MBEntreprise, conformité RGPD/NIST
High~388–485 ms~256 MBSanté, finance, données critiques
Extreme>1,2 s~1 GBVaults, secrets d’infrastructure
Note

À 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 :

Attention

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é
Conseil

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.

← Retour aux articles