Back to 0cron
0cron

Secrets chiffrés, clés API, et sécurité

Chiffrement AES-256-GCM, interpolation ${secrets.KEY}, authentification JWT + clé API, vérification Google Sign-In, et signature HMAC des webhooks -- les couches de sécurité de 0cron.

Juste A. Gnimavo (Thales) & Claude | March 26, 2026 8 min 0cron
EN/ FR/ ES
0cronsecurityencryptionaes-gcmjwtapi-keysgoogle-auth

Un service de tâches cron qui fait des requêtes HTTP pour le compte des utilisateurs est, par définition, un service qui manipule des identifiants. Vos tâches appellent des API qui nécessitent une authentification. Ces clés API, tokens bearer, et secrets webhook doivent vivre quelque part -- et ce quelque part ferait mieux d'être chiffré, contrôlé en accès, et auditable.

0cron a quatre couches de sécurité distinctes : stockage de secrets chiffrés pour les identifiants utilisateur, authentification JWT pour les sessions du tableau de bord, authentification par clé API pour l'accès programmatique, et vérification externe pour Google Sign-In et les webhooks Stripe. Chaque couche sert un objectif différent, et ensemble elles forment une architecture de défense en profondeur qui protège à la fois la plateforme et ses utilisateurs.

Cet article parcourt les quatre couches avec le code Rust réel.

Couche 1 : secrets chiffrés (AES-256-GCM)

Quand un utilisateur stocke une clé API dans 0cron -- par exemple son URL webhook Slack ou un token bearer tiers -- cette valeur est chiffrée avant de toucher la base de données. Nous utilisons AES-256-GCM, qui est le standard d'excellence pour le chiffrement authentifié. « Authentifié » signifie que le déchiffrement non seulement récupère le texte en clair mais vérifie aussi que le texte chiffré n'a pas été altéré.

Voici la fonction de chiffrement :

rustpub fn encrypt_secret(plaintext: &str, key: &[u8]) -> AppResult<Vec<u8>> {
    let key = aes_gcm::Key::<Aes256Gcm>::from_slice(key);
    let cipher = Aes256Gcm::new(key);
    let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
    let ciphertext = cipher.encrypt(&nonce, plaintext.as_bytes())
        .map_err(|e| AppError::Encryption(format!("Encryption failed: {e}")))?;
    let mut result = nonce.to_vec();
    result.extend_from_slice(&ciphertext);
    Ok(result)
}

Et le déchiffrement correspondant :

rustpub fn decrypt_secret(ciphertext: &[u8], key: &[u8]) -> AppResult<String> {
    if ciphertext.len() < 12 { return Err(AppError::Encryption("Ciphertext too short".to_string())); }
    let key = aes_gcm::Key::<Aes256Gcm>::from_slice(key);
    let cipher = Aes256Gcm::new(key);
    let nonce = Nonce::from_slice(&ciphertext[..12]);
    let plaintext = cipher.decrypt(nonce, &ciphertext[12..])
        .map_err(|e| AppError::Encryption(format!("Decryption failed: {e}")))?;
    String::from_utf8(plaintext).map_err(|e| AppError::Encryption(format!("Invalid UTF-8: {e}")))
}

Plusieurs décisions de conception sont intégrées dans ces 25 lignes.

Nonces aléatoires depuis OsRng. Chaque opération de chiffrement génère un nonce frais de 12 octets utilisant le générateur de nombres aléatoires cryptographiquement sécurisé du système d'exploitation.

Texte chiffré préfixé par le nonce. Le format de stockage est nonce || ciphertext -- les 12 premiers octets sont le nonce, et tout ce qui suit est les données chiffrées plus le tag d'authentification GCM.

La clé de chiffrement est un secret côté serveur. Elle est chargée depuis une variable d'environnement au démarrage et jamais stockée en base de données. Si la base de données est compromise, l'attaquant obtient des blobs chiffrés inutiles sans la clé.

Interpolation des secrets dans les configurations de tâches

rustpub async fn interpolate_secrets(text: &str, team_id: Uuid, db: &PgPool, key: &[u8]) -> AppResult<String> {
    let re = Regex::new(r"\$\{secrets\.([A-Za-z0-9_]+)\}").unwrap();
    let mut result = text.to_string();
    for (full_match, key_name) in re.captures_iter(text).map(|cap| (cap[0].to_string(), cap[1].to_string())) {
        let row: Option<(Vec<u8>,)> = sqlx::query_as("SELECT value_encrypted FROM secrets WHERE team_id = $1 AND key = $2")
            .bind(team_id).bind(&key_name).fetch_optional(db).await?;
        match row {
            Some((encrypted,)) => { result = result.replace(&full_match, &decrypt_secret(&encrypted, key)?); }
            None => return Err(AppError::NotFound(format!("Secret '{key_name}' not found"))),
        }
    }
    Ok(result)
}

Cette fonction s'exécute au moment de l'exécution, pas au moment de la création de la tâche. La regex \$\{secrets\.([A-Za-z0-9_]+)\} correspond à des patterns comme ${secrets.SLACK_TOKEN}, extrait le nom de la clé, cherche la valeur chiffrée en base de données (scopée à l'équipe), la déchiffre, et la substitue dans le texte.

Cette architecture a plusieurs avantages en termes de sécurité :

  • Les secrets ne sont jamais stockés en clair dans les configurations de tâches. La base de données stocke Authorization: Bearer ${secrets.API_TOKEN}, pas le token réel.
  • Les secrets sont scopés à l'équipe. La requête filtre par team_id, donc une équipe ne peut pas référencer les secrets d'une autre.
  • Le déchiffrement se fait en mémoire et n'est jamais journalisé.
  • Les secrets manquants sont des erreurs dures. Si une tâche référence ${secrets.OLD_TOKEN} et que ce secret a été supprimé, l'interpolation retourne une erreur plutôt que d'envoyer une requête avec la chaîne littérale.

Couche 2 : authentification JWT

Les utilisateurs du tableau de bord s'authentifient via des JSON Web Tokens. Après la connexion (via Google ou e-mail/mot de passe), le serveur émet un JWT que le frontend stocke dans le store d'auth. Chaque requête API suivante inclut ce token dans l'en-tête Authorization.

Nous utilisons HS256 (HMAC-SHA256) avec un secret côté serveur, ce qui est approprié pour une architecture mono-serveur. Si nous scalons vers plusieurs serveurs, nous passerions à RS256 avec une paire clé publique/privée.

Couche 3 : authentification par clé API

Les développeurs intègrent 0cron dans leurs pipelines CI/CD et leurs scripts personnalisés. Ces environnements ont besoin d'un identifiant de longue durée qui n'expire pas toutes les quelques heures comme un JWT.

Un utilisateur génère une clé dans la page de paramètres, et 0cron la retourne une seule fois. La clé brute n'est jamais stockée. Au lieu de cela, le serveur stocke un hash Argon2 de la clé avec un préfixe (les 8 premiers caractères) pour la recherche.

Le flux d'authentification fonctionne en deux étapes. D'abord, le middleware extrait le préfixe de la clé fournie et interroge la base de données pour les enregistrements de clé API correspondants. Ensuite, il vérifie la clé complète contre le hash Argon2 stocké. Ce processus en deux étapes évite de hasher chaque clé de la base de données contre la valeur fournie.

Les deux méthodes d'authentification -- JWT et clé API -- produisent le même struct AuthUser. Du point de vue du handler, la méthode d'authentification est invisible.

Couche 4 : vérification externe

Vérification Google Sign-In

Le flux de vérification comporte quatre étapes : décoder l'en-tête JWT pour extraire le kid, récupérer les clés publiques de Google depuis l'endpoint JWKS, vérifier la signature RS256, et valider les claims (aud, iss, expiration, email_verified).

Vérification de signature webhook Stripe

rustfn verify_stripe_signature(payload: &[u8], sig_header: &str, secret: &str) -> AppResult<()> {
    // Parse t= and v1= from header
    let signed_payload = format!("{timestamp}.{}", std::str::from_utf8(payload).unwrap_or(""));
    let mut mac = HmacSha256::new_from_slice(secret.as_bytes())?;
    mac.update(signed_payload.as_bytes());
    let expected = hex::encode(mac.finalize().into_bytes());
    // Constant-time compare + 5-minute timestamp tolerance
}

Deux détails sont critiques : la comparaison en temps constant (pour ne pas fuiter d'information de timing) et la tolérance d'horodatage (5 minutes pour empêcher les attaques par rejeu).

L'architecture de sécurité dans son ensemble

Ces quatre couches interagissent pour créer un modèle de sécurité cohérent. Ce qui les lie est le struct AuthUser. Quelle que soit la manière dont une requête est authentifiée -- JWT, clé API, ou middleware admin -- le résultat est le même objet d'identité avec le même modèle de permissions. Les handlers ne se soucient pas de comment vous avez prouvé votre identité. Ils se soucient que vous l'ayez prouvée et que votre team ID corresponde aux ressources que vous demandez.

Ce que nous avons choisi de ne pas faire

Pas de chiffrement côté client. Le chiffrement côté client rendrait l'interpolation des secrets impossible -- le serveur a besoin de déchiffrer les secrets pour les injecter dans les requêtes HTTP.

Pas de versioning des secrets. Quand un utilisateur met à jour un secret, l'ancienne valeur est écrasée. Cela simplifie le modèle de données.

Pas de TLS mutuel. Pour un produit SaaS accessible via HTTPS standard, c'est le modèle de sécurité attendu.

Pas de module de sécurité matériel. La clé de chiffrement est une variable d'environnement. Un HSM fournirait une meilleure protection des clés, mais ajoute du coût et de la complexité d'infrastructure. Pour un service à 1,99 $/mois, le modèle de menace ne justifie pas l'intégration HSM au lancement.

Le module de secrets de 93 lignes

L'intégralité du module de secrets -- chiffrement, déchiffrement, et interpolation -- fait 93 lignes de Rust. Le middleware d'auth fait 86 lignes. Combinés avec la vérification Google auth et la vérification webhook Stripe, le total du code lié à la sécurité fait moins de 400 lignes.

Ce n'est pas parce que la sécurité a été traitée à la légère. C'est parce que nous avons utilisé des bibliothèques cryptographiques bien vérifiées (aes-gcm, jsonwebtoken, argon2, hmac) et les avons composées avec une fine couche de logique applicative.

La sécurité n'est pas une fonctionnalité qu'on ajoute à la fin. C'est une propriété de l'architecture. Dans 0cron, les secrets étaient chiffrés dès le premier jour, l'authentification était requise dès le premier endpoint, et les intégrations externes étaient vérifiées dès le premier webhook.


Ceci est l'article 9 de 10 dans la série « Comment nous avons construit 0cron ».

  1. Pourquoi le monde a besoin d'un service cron à 2 $
  2. 4 agents, 1 produit : construire 0cron en une seule session
  3. Construire un moteur de planification cron en Rust
  4. "Tous les jours à 9 h" : parsing de planification en langage naturel
  5. Notifications multi-canaux : e-mail, Slack, Discord, Telegram, webhooks
  6. Intégration Stripe pour un SaaS à 1,99 $/mois
  7. Du HTML statique au tableau de bord SvelteKit en une nuit
  8. Monitoring heartbeat : quand votre tâche devrait vous pinguer
  9. Secrets chiffrés, clés API, et sécurité (vous êtes ici)
  10. D'Abidjan à la production : lancement de 0cron.dev
Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles

Thales & Claude thales

Treize agents, quarante-trois minutes : la première session Workflow de Claude Fable 5, et ce qu'un script d'orchestration déterministe change aux builds multi-agents

Un prompt, treize agents, quarante-trois minutes : la première session de production avec Claude Fable 5 et l'outil Workflow de Claude Code a livré un site web de production complet de sept pages plus un endpoint backend de capture de leads, en un seul commit. Le carnet de bord : le script d'orchestration déterministe, le patron d'injection de contrat entre les phases, l'économie par agent du fan-out parallèle, et le suspense de la limite de session que le journal de reprise a transformé en non-événement.

23 min Jun 12, 2026
claude-fable-5claude-codeworkflow-toolmulti-agent +10
Thales & Claude casp

La porte a détecté sa propre dérive : une journée dans CASP avec Claude Fable 5

Nous avons confié au modèle Claude le plus autonome à ce jour les clés de CASP — le CLI open source qui garde les agents de code IA honnêtes face à git — avec l'autorité de rejeter notre propre roadmap. Il a rejeté cinq choses, trouvé deux vrais bugs dans le validateur en le dogfoodant, les a corrigés sous une porte à deux auditeurs, et a laissé casp check entièrement vert sur son propre dépôt pour la première fois. CASP 0.3.0 en est le résultat.

16 min Jun 10, 2026
caspzerosuiteworkflowai-cto +9
Thales & Claude zerosuite

La transplantation du CASP : comment la discipline des six fichiers est passée de Conductor à un ERP transport anti-fraude, ce que la compétence /next ajoute quand l'opérateur tape juste « next », et pourquoi le coût d'une dérive du CASP grimpe quand le projet, c'est l'argent des autres

La discipline du CASP qui a piloté trente-cinq sessions de Conductor est agnostique au produit. Le carnet de bord de sa transplantation sur KASSIA, un ERP transport anti-fraude pour un exploitant de flotte en Côte d'Ivoire : ce qui a migré, ce qui n'a pas migré (le validateur sur mesure — et ce que son absence coûte), ce que la compétence /next ajoute quand l'opérateur tape un seul mot, et là où le CASP s'arrête — le bug de déploiement qu'il ne pouvait pas voir parce qu'il enregistre l'intention, pas la réalité de l'infrastructure.

23 min Jun 8, 2026
kassiaerp-kassia-transport-logistiquezerosuiteCASP +15