Guide pratique

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.

Comparer les deux modes

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éesRotation recommandée
key-users-piiNoms, emails, téléphones, adresses90 jours
key-users-authTokens de refresh, secrets 2FA30 jours
key-financialIBAN, numéros CB, montants sensibles30 jours
key-healthDossiers patients, diagnostics, ordonnances30 jours
key-internalDonnées internes non réglementées180 jours
key-stagingDonnées de test uniquementManuel

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.

ServicePermissions accordées
Backend principal (mode enveloppe)keys:wrap
Service qui chiffre sans jamais lirekeys:wrap
Service analytiqueaudit:read
Pipeline CI/CDkeys:read
Provisionnement de cléskeys:read, keys:write
Sortie de plateforme, ponctuellekeys: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.

EnvironnementExpiration recommandée
Production90 jours → rotation trimestrielle
Staging30 jours
CI/CD7 jours ou usage unique
DéveloppementPas d'expiration (local uniquement)

3. Rotation automatique des clés

Données de santé, financières30 jours
PII standard (email, téléphone)90 jours
Données internes non sensibles180 jours

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.