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