Architecture QR Code Sécurisé — NxPay
Documentation technique du système de QR Code chiffré dynamique. Basée sur l'implémentation réelle du code source.
1. Objectif
Mettre en place un système de QR Code sécurisé et dynamique permettant d'initier différents types de transactions :
- transfert P2P
- cash-in via agent
- cash-out via agent
- paiement marchand
Le QR Code sert uniquement à identifier le wallet ou le contexte de transaction. La transaction réelle (montant, confirmation, authentification) se fait dans l'application après le scan.
Ce modèle s'inspire des mécanismes utilisés dans certaines fintech comme Wave.
2. Objectifs de sécurité
Le système garantit :
- QR impossible à falsifier (chiffrement AES-256-GCM avec authentification)
- QR expirant rapidement (rotation côté client toutes les 30 secondes)
- QR non réutilisable (protection anti-replay par nonce)
- Fonctionnement offline (génération côté client sans appel API)
- Validation côté serveur (déchiffrement + vérification de fraîcheur)
3. Architecture globale
Utilisateur / Agent affiche QR
↓
Scanner avec l'application
↓
App appelle POST /qr/resolve
↓
Backend déchiffre et valide le token
↓
Backend retourne le contexte (wallet, type)
↓
App ouvre l'écran de transaction4. Contenu du QR
Phase actuelle : le QR contient uniquement le token brut, sans URL.
{kid_b64url}.{iv_b64url}.{ciphertext+authTag_b64url}Le scan nécessite l'application NxPay (le QR n'est pas lisible par un scanner générique ou le navigateur).
Évolution prévue — URL HTTPS
À terme, le QR pourra encapsuler le token dans une URL HTTPS :
https://paywithnex.com/qr/{token}Avantages :
- Compatible avec tous les scanners (appareil photo natif iOS/Android)
- Deep link vers l'application mobile si installée
- Fallback vers page de téléchargement si l'app est absente
| Situation | Comportement |
|---|---|
| App installée | Deep link : app://qr?token=... |
| App non installée | Redirection : https://paywithnex.com/download |
5. Format du token
Le token est composé de 3 segments séparés par ., encodés en base64url :
{kid_b64url}.{iv_b64url}.{ciphertext+authTag_b64url}| Segment | Contenu | Taille |
|---|---|---|
kid | Key Identifier (identifie la clé dans Redis/BDD) | 16 octets → 22 chars b64url |
iv | Vecteur d'initialisation AES-GCM | 12 octets → 16 chars b64url |
ciphertext+authTag | Payload chiffré + tag d'authentification GCM | variable |
Note : L'ancien format
base64(payload).signature(2 segments, HMAC) est obsolète et n'est plus utilisé.
6. Structure du Payload (en clair, avant chiffrement)
{
"v": 1,
"uid": "uuid-du-wallet",
"type": "p2p",
"ts": 1772190427,
"nonce": "a3f9c2b1d0e4f5a6b7c8d9e0f1a2b3c4",
"lot": 1772180000
}Description des champs
| Champ | Description | Type |
|---|---|---|
v | Version du format (actuellement 1) | number |
uid | UUID du wallet (= auth.users.id) | string UUID |
type | Type de transaction | string (voir tableau) |
ts | Timestamp Unix de génération (secondes) | number |
nonce | Valeur aléatoire 16 octets (32 hex chars) | string |
lot | Last Online Timestamp — dernier heartbeat confirmé | number |
Types de transactions supportés
| Type | Description |
|---|---|
p2p | Transfert utilisateur à utilisateur |
cash_in | Agent crédite un client |
cash_out | Agent remet du cash à un client |
merchant | Paiement commerçant |
7. Chiffrement AES-256-GCM
Le payload est chiffré (et non simplement signé) avec AES-256-GCM.
ciphertext+authTag = AES_256_GCM_Encrypt(payload_json, device_key, iv)- Algorithme : AES-256-GCM (NIST standard)
- Clé : 32 octets (256 bits) — le
deviceKey - IV : 12 octets aléatoires, générés à chaque QR
- Auth Tag : 16 octets, inclus en fin de ciphertext (garantit l'intégrité)
L'auth tag de GCM garantit simultanément la confidentialité et l'authenticité du payload. Toute altération est détectée lors du déchiffrement.
8. Obtention du device_key
Vue d'ensemble
Le deviceKey est une clé AES-256 aléatoire, générée par le backend à chaque login, liée à un utilisateur. Elle est distribuée une seule fois lors de l'authentification et stockée côté client.
Un kid (Key Identifier) accompagne le deviceKey pour permettre au backend de retrouver la clé correspondante lors de la résolution du QR.
Étape 1 — Login
POST /auth/login/phone-pinBody :
{
"countryCode": "242",
"phoneNumber": "XXXXXXXX",
"pin": "123456"
}Étape 2 — Génération côté backend
// services/auth/src/auth/phone-auth.service.ts (lignes 94-97)
const kid = crypto.randomBytes(16).toString('hex'); // 32 hex chars
const deviceKey = crypto.randomBytes(32).toString('hex'); // 64 hex chars
await this.redisService.setQrDeviceKey(kid, deviceKey, user.id);kid: identifiant aléatoire 16 octets, permet de retrouver la clé dans Redis/BDDdeviceKey: clé AES-256 aléatoire 32 octets- Persistance : Redis (cache rapide) + table
auth.qr_device_keys(durabilité)
Étape 3 — Réponse d'authentification
{
"access_token": "eyJ...",
"refresh_token": "...",
"token_type": "Bearer",
"expires_in": 3600,
"qr": {
"kid": "a93bf9a5d19e4f2c...",
"deviceKey": "3f2a1b9c8d7e6f5a4b3c2d1e0f9a8b7c...",
"userId": "uuid-du-user"
}
}Étape 4 — Stockage côté application mobile
// apps/mobile-client/core/services/auth.ts (lignes 345-351)
await storageService.setItem(STORAGE_KEYS.QR_KID, response.data.qr.kid);
await storageService.setItem(STORAGE_KEYS.QR_DEVICE_KEY, response.data.qr.deviceKey);
await storageService.setItem(STORAGE_KEYS.QR_USER_ID, response.data.qr.userId);Clés de stockage (AsyncStorage) :
| Clé | Valeur |
|---|---|
@nex:qr_kid | kid hex (32 chars) |
@nex:qr_device_key | deviceKey hex (64 chars) |
@nex:qr_user_id | UUID de l'utilisateur |
Note sécurité : Le stockage actuel utilise AsyncStorage (React Native). Pour une sécurité maximale, migrer vers iOS Keychain / Android Keystore est recommandé.
9. Génération du QR côté application (offline)
Le QR peut être généré sans connexion réseau.
// apps/mobile-client/store/qrCodeStore.ts (lignes 449-518)
// 1. Récupérer les clés stockées
const kid = await storageService.getItem(STORAGE_KEYS.QR_KID);
const deviceKeyHex = await storageService.getItem(STORAGE_KEYS.QR_DEVICE_KEY);
// 2. Convertir la clé hex → Uint8Array (32 octets)
const keyBytes = new Uint8Array(
(deviceKeyHex.match(/.{2}/g) ?? []).map((b) => parseInt(b, 16))
);
// 3. IV aléatoire 12 octets
const iv = ExpoCrypto.getRandomValues(new Uint8Array(12));
// 4. Nonce aléatoire 16 octets
const nonceBytes = ExpoCrypto.getRandomValues(new Uint8Array(16));
const nonce = Array.from(nonceBytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
// 5. Construire le payload
const payload = JSON.stringify({
v: 1,
uid: userId,
ts: Math.floor(Date.now() / 1000),
nonce,
lot: lastOnlineTimestamp,
});
// 6. Chiffrer avec AES-256-GCM (@noble/ciphers)
const cipher = gcm(keyBytes, iv);
const encrypted = cipher.encrypt(new TextEncoder().encode(payload));
// encrypted = ciphertext + authTag (16 octets) concaténés
// 7. Encoder les segments en base64url
const token = `${toBase64url(kidBytes)}.${toBase64url(iv)}.${toBase64url(encrypted)}`;
// 8. URL finale dans le QR
const qrUrl = `https://paywithnex.com/qr/${token}`;Dépendances mobiles :
expo-crypto: génération de bytes aléatoires cryptographiquement sûrs@noble/ciphers/aes: implémentation pure JS d'AES-GCM (pas decrypto.subtlenécessaire)
10. Rotation du QR
Le QR est régénéré automatiquement côté client.
// Rotation toutes les 30 secondes
const LOCAL_QR_TTL_SECONDS = 30;
setInterval(() => {
generateLocalQr(userId);
}, LOCAL_QR_TTL_SECONDS * 1000);Un compteur visible affiche le temps restant (décrément chaque seconde).
11. Résolution du QR (backend)
Lorsqu'un QR est scanné :
POST /qr/resolveBody :
{
"token": "kid_b64url.iv_b64url.ciphertext_b64url"
}Flux de validation (qr-validation.service.ts)
1. Parser le token (split sur '.')
↓
2. Décoder base64url → kid, iv, ciphertext+authTag
↓
3. Récupérer deviceKey depuis Redis (ou BDD en fallback)
↓
4. Déchiffrer avec AES-256-GCM
(échec si auth tag invalide → QR falsifié ou corrompu)
↓
5. Vérifier version (v === 1)
↓
6. Vérifier uid === userId de la clé (anti-usurpation)
↓
7. Vérifier fraîcheur : now - payload.ts < TTL (300 secondes)
↓
8. Vérifier nonce (non déjà utilisé → Redis)
↓
9. Poser un lock exclusif par agent (anti-double scan)
↓
10. Retourner contexte : { wallet, type, lastOnlineAt }Stockage des clés (backend)
| Couche | Rôle |
|---|---|
| Redis | Cache rapide des kid → deviceKey (lookup en < 1ms) |
PostgreSQL (auth.qr_device_keys) | Persistance durable (fallback si Redis manque) |
12. Vérification expiration
tokenAge = now - payload.ts
TTL = 300 secondes (5 minutes, configurable via QR_TOKEN_TTL)
Si tokenAge < 0 ou tokenAge > TTL → TOKEN_EXPIREDLe client régénère toutes les 30 secondes, le serveur tolère jusqu'à 5 minutes pour absorber les décalages d'horloge et les cas offline.
13. Protection contre le replay
Chaque nonce est utilisé une seule fois.
- Stockage : Redis
- Après résolution : nonce marqué comme utilisé
- Tout re-scan avec le même nonce → rejeté
De plus, un lock exclusif par agent empêche deux scans simultanés du même QR.
14. Rotation et révocation du device_key
Rotation automatique
Le backend génère un nouveau kid + deviceKey à chaque login. L'ancienne clé est remplacée en base.
Révocation globale
// Déconnexion d'un appareil spécifique
await qrDeviceKeyService.deleteByKid(kid);
// Déconnexion de tous les appareils d'un utilisateur
await qrDeviceKeyService.deleteByUserId(userId);15. Mesures de sécurité supplémentaires
| Mesure | Détail |
|---|---|
| Rate limiting | À appliquer sur POST /qr/resolve |
| Rotation du device_key | À chaque login ou changement d'appareil |
| TTL strict | 300 secondes serveur, 30 secondes client |
| AES-GCM auth tag | Toute falsification détectée lors du déchiffrement |
| UID mismatch check | Vérifie que le uid du payload correspond bien au propriétaire de la clé |
16. Avantages de cette architecture
- Fonctionne offline (génération sans réseau)
- QR dynamique (nouveau token toutes les 30 secondes)
- Impossible à falsifier (AES-256-GCM avec auth tag)
- Protection contre le replay (nonce + Redis)
- Extensible à plusieurs types de transactions
- Révocable (suppression de la clé en base suffit)
17. Fichiers clés
Backend — Service auth
| Fichier | Rôle |
|---|---|
services/auth/src/auth/phone-auth.service.ts | Génération de kid + deviceKey au login |
services/auth/src/auth/entities/qr-device-key.entity.ts | Entité TypeORM (auth.qr_device_keys) |
services/auth/src/auth/qr-device-key.service.ts | CRUD du device key (Redis + BDD) |
services/auth/src/auth/qr-validation.service.ts | Déchiffrement et validation complète du token |
services/auth/src/auth/auth.controller.ts | Endpoint POST /auth/login/phone-pin |
Mobile Client (apps/mobile-client)
| Fichier | Rôle |
|---|---|
core/services/auth.ts | Login et stockage du kid + deviceKey |
core/services/storage.ts | Constantes STORAGE_KEYS |
store/qrCodeStore.ts | Génération locale du QR + rotation |
Mobile Pro (apps/mobile-pro)
| Fichier | Rôle |
|---|---|
core/services/auth.ts | Même flow que mobile-client |
store/qrCodeStore.ts | Même logique de génération |
18. Schéma de flux complet
┌─────────────────────────────────────────────────────────────────┐
│ LOGIN │
│ │
│ App ──POST /auth/login/phone-pin──► Auth Service │
│ │ │
│ kid = random(16 bytes) │
│ deviceKey = random(32 bytes) │
│ Redis.set(kid, deviceKey) │
│ DB.upsert(kid, deviceKey) │
│ │ │
│ App ◄── { access_token, qr: { kid, deviceKey, userId } } ────┘
│
│ AsyncStorage.set(QR_KID, kid)
│ AsyncStorage.set(QR_DEVICE_KEY, deviceKey)
│ AsyncStorage.set(QR_USER_ID, userId)
│
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ GÉNÉRATION QR (toutes les 30s) │
│ │
│ iv = random(12 bytes) │
│ nonce = random(16 bytes) │
│ payload = { v:1, uid, type, ts, nonce, lot } │
│ encrypted = AES_256_GCM(payload, deviceKey, iv) │
│ token = b64url(kid) + "." + b64url(iv) + "." + b64url(enc) │
│ QR = "https://paywithnex.com/qr/" + token │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ SCAN & RÉSOLUTION │
│ │
│ Scanner ──► App ──POST /qr/resolve { token }──► QR Service │
│ │ │
│ parse token (3 parts) │
│ Redis.get(kid) │
│ AES_GCM_decrypt │
│ verify v, uid, ts, nonce│
│ lock agent │
│ │ │
│ App ◄─── { wallet, type, lastOnlineAt } ──────────────┘ │
│ │
│ App ouvre l'écran de transaction │
└─────────────────────────────────────────────────────────────────┘