Bonnes pratiques : la checklist sécurité
Recommandations concrètes pour utiliser MAFATE de façon sécurisée : séparation des clés, moindre privilège, rotation, logging sûr, gestion des erreurs et tests.
Ces exemples chiffrent en local, et ce n’est pas un choix de style
Tout compte créé depuis le 11/08/2026 est en mode enveloppe : /v1/encrypt et /v1/decrypt répondent 403 E_ENVELOPE_ONLY. Vos données sont chiffrées chez vous, MAFATE ne reçoit que la clé de données à emballer. Le mode serveur reste possible sur demande, il est décrit dans la référence d’API.
1. Séparation des clés par domaine
Ne jamais utiliser une seule clé pour tout chiffrer. Une clé par domaine de données limite la surface d'exposition : si une clé est compromise, seules les données de ce domaine sont affectées.
| Nom de la clé | Données concernées | Rotation recommandée |
|---|---|---|
| key-users-pii | Noms, emails, téléphones, adresses | 90 jours |
| key-users-auth | Tokens de refresh, secrets 2FA | 30 jours |
| key-financial | IBAN, numéros CB, montants sensibles | 30 jours |
| key-health | Dossiers patients, diagnostics, ordonnances | 30 jours |
| key-internal | Données internes non réglementées | 180 jours |
| key-staging | Données de test uniquement | Manuel |
2. Moindre privilège pour les clés API
Chaque service doit avoir une clé API distincte, avec les seules permissions dont il a besoin. En mode enveloppe, le chemin nominal tient dans une seule : keys:wrap, qui emballe et désemballe. Un service analytique n'en a pas besoin, et personne n'a besoin de keys:export au quotidien.
| Service | Permissions accordées |
|---|---|
| Backend principal (mode enveloppe) | keys:wrap |
| Service qui chiffre sans jamais lire | keys:wrap |
| Service analytique | audit:read |
| Pipeline CI/CD | keys:read |
| Provisionnement de clés | keys:read, keys:write |
| Sortie de plateforme, ponctuelle | keys:export |
| Backend en mode serveur (exception) | encrypt, decrypt |
2b. Sécuriser vos clés API
Au-delà des permissions, deux mesures renforcent significativement la sécurité de vos clés API :
Restriction par IP
Configurez les adresses IP autorisées pour chaque clé API. Seules les requêtes provenant de ces IPs seront acceptées (les autres reçoivent un 403 Forbidden). Supporte les IPs exactes et les plages CIDR.
# Créer une clé restreinte à votre VPC
curl -s -X POST https://api.mafate.io/v1/api-keys \
-H "Authorization: Bearer $MAFATE_ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "backend-prod",
"permissions": ["keys:wrap"],
"allowed_ips": ["10.0.1.0/24", "10.0.2.0/24"]
}'Fortement recommandé en production. Même si votre clé API est compromise, l'attaquant ne peut pas l'utiliser depuis une IP non autorisée.
Date d'expiration
Définissez une date d'expiration pour vos clés API. Après cette date, la clé est automatiquement rejetée. Prévoyez la rotation avant l'expiration.
| Environnement | Expiration recommandée |
|---|---|
| Production | 90 jours → rotation trimestrielle |
| Staging | 30 jours |
| CI/CD | 7 jours ou usage unique |
| Développement | Pas d'expiration (local uniquement) |
3. Rotation automatique des clés
4. Ne jamais logger des données en clair
Les logs sont souvent mal protégés (Datadog, Splunk, S3...). Un PII dans les logs annule tous vos efforts de chiffrement.
import { Mafate } from '@mafate/sdk'
const mafate = new Mafate({ apiKey: process.env.MAFATE_API_KEY })
const KEY_ID = '7c9e6679-7425-40de-944b-e07fc1f90ae7'
// MAUVAIS : ne jamais logger de donnees PII en clair
function saveUserBAD(email: string, phone: string) {
console.log('Enregistrement utilisateur:', { email, phone }) // INTERDIT
logger.info('User saved', { email }) // INTERDIT
}
// BON : logger uniquement une empreinte ou un identifiant tronque
async function emailHash(email: string): Promise<string> {
// ⚠️ `hash` est le SEUL appel qui transmet du clair a MAFATE, meme en mode
// enveloppe. C'est le prix de la recherche sur un champ chiffre, et il doit
// etre un choix conscient : n'y passez que ce que vous devez chercher.
return await mafate.hash(email.toLowerCase(), KEY_ID)
}
async function saveUserGOOD(email: string, phone: string, userId: string) {
const hash = await emailHash(email)
console.log('Enregistrement utilisateur:', {
userId,
emailHash: hash.slice(0, 8) + '...',
hasPhone: Boolean(phone),
})
}5. Gestion des erreurs et résilience
En cas d'indisponibilité temporaire de l'API MAFATE, réessayez avec une temporisation exponentielle. Ne jamais stocker en clair comme repli. Et ne réessayez jamais un 4xx : un refus de permission ou de mode ne devient pas un accord à la troisième tentative.
import { Mafate, ApiError, ConnectionError, TimeoutError } from '@mafate/sdk'
const mafate = new Mafate({ apiKey: process.env.MAFATE_API_KEY })
// ⚠️ C'est `ApiError` qui porte `status`, `title` et `detail`.
// `MafateError` est la classe de base : elle n'a pas de statut, et un
// `instanceof MafateError && err.status === 429` ne filtre donc rien.
async function encryptWithRetry(
value: string,
keyId: string,
maxAttempts = 3
): Promise<object> {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
// Mode enveloppe : le clair ne quitte pas ce processus.
return await mafate.encryptLocal(value, keyId)
} catch (err) {
// Un reseau coupe ou un delai depasse merite une nouvelle tentative.
const reseau = err instanceof ConnectionError || err instanceof TimeoutError
const serveur = err instanceof ApiError && err.status >= 500
if ((reseau || serveur) && attempt < maxAttempts) {
const delay = Math.pow(2, attempt) * 200
await new Promise(r => setTimeout(r, delay + Math.random() * 100))
continue
}
// ⛔ NE JAMAIS REESSAYER UN 403 E_ENVELOPE_ONLY : ce n'est pas un incident
// passager, c'est le mode du locataire. Insister ne fera que remplir
// votre journal d'audit de refus.
if (err instanceof ApiError && err.status === 403) {
throw new Error(`refus definitif : ${err.detail}`)
}
throw err
}
}
throw new Error('Max retry attempts reached')
}
// Coupe-circuit
class CryptoCircuitBreaker {
private failures = 0
private lastFailure = 0
private readonly threshold = 5
private readonly timeout = 30_000
async encrypt(value: string, keyId: string) {
if (this.failures >= this.threshold) {
if (Date.now() - this.lastFailure < this.timeout) {
throw new Error('Circuit ouvert : service crypto indisponible')
}
this.failures = 0 // demi-ouverture
}
try {
const result = await encryptWithRetry(value, keyId)
this.failures = 0
return result
} catch (err) {
this.failures++
this.lastFailure = Date.now()
throw err
}
}
}6. Tests en CI/CD
Intégrez des tests de chiffrement dans votre pipeline, sur une clé de test dédiée et jamais celle de production. Passez son identifiant par variable d’environnement : c’est un UUID, il change d’un environnement à l’autre.
import { describe, it, expect } from 'vitest'
import { Mafate, ApiError } from '@mafate/sdk'
// Une cle dediee aux tests, jamais celle de production.
const mafate = new Mafate({ apiKey: process.env.MAFATE_TEST_API_KEY })
const TEST_KEY_ID = process.env.MAFATE_TEST_KEY_ID! // UUID rendu a la creation
describe('Chiffrement en mode enveloppe', () => {
it('chiffre et dechiffre un e-mail', async () => {
const email = '[email protected]'
const env = await mafate.encryptLocal(email, TEST_KEY_ID)
// L'enveloppe se stocke telle quelle : six champs, tous necessaires.
expect(env).toMatchObject({ key_id: TEST_KEY_ID, envelope_version: 1 })
expect(env.ciphertext).not.toBe(email)
expect(Buffer.from(env.iv, 'base64')).toHaveLength(12)
expect(await mafate.decryptLocalToString(env)).toBe(email)
})
it('produit deux chiffres differents pour la meme valeur', async () => {
const email = '[email protected]'
const a = await mafate.encryptLocal(email, TEST_KEY_ID)
const b = await mafate.encryptLocal(email, TEST_KEY_ID)
// Un IV neuf est tire a chaque appel : deux chiffres identiques
// signaleraient une reutilisation, qui casserait GCM en silence.
expect(a.ciphertext).not.toBe(b.ciphertext)
expect(a.iv).not.toBe(b.iv)
expect(await mafate.decryptLocalToString(b)).toBe(email)
})
it('refuse une enveloppe alteree', async () => {
const env = await mafate.encryptLocal('secret', TEST_KEY_ID)
const altere = { ...env, ciphertext: Buffer.from('nawak').toString('base64') }
// Le tag GCM est verifie EN LOCAL : l'echec ne coute pas un appel reseau.
await expect(mafate.decryptLocalToString(altere)).rejects.toThrow()
})
it('refuse un wrapped_key qui ne vient pas de cette cle', async () => {
const env = await mafate.encryptLocal('secret', TEST_KEY_ID)
const autre = { ...env, key_id: process.env.MAFATE_OTHER_KEY_ID! }
// Cote serveur : 403, l'AAD du jeton ne correspond pas.
await expect(mafate.decryptLocalToString(autre)).rejects.toThrow(ApiError)
})
})7. Crypto-shredding (RGPD Art.17)
Pour satisfaire le droit à l'effacement, désactivez la clé de chiffrement de l'utilisateur. Toutes ses données chiffrées deviennent immédiatement et définitivement illisibles, sans avoir à parcourir la base.
import { Mafate } from '@mafate/sdk'
const mafate = new Mafate({ apiKey: process.env.MAFATE_API_KEY })
// RGPD art. 17 : effacement par destruction de la cle.
// Suppose une cle dediee par utilisateur, sans quoi la destruction
// emporterait les donnees de tout le monde.
async function deleteUserData(userId: string): Promise<void> {
const user = await db.query('SELECT key_id FROM users WHERE id = $1', [userId])
if (user.rows[0]?.key_id) {
// `disable()` envoie DELETE /v1/keys/{id} : desactivation PUIS destruction
// des DEK emballees de toutes les versions. Irreversible.
await mafate.keys.disable(user.rows[0].key_id)
}
await db.query('DELETE FROM users WHERE id = $1', [userId])
await db.query(
'INSERT INTO gdpr_deletions (user_id, deleted_at, method) VALUES ($1, NOW(), $2)',
[userId, 'crypto-shredding']
)
}
// ⚠️ Avec une cle partagee, supprimer la ligne suffit : les enveloppes des
// autres utilisateurs restent dechiffrables, ce qui est le comportement voulu.