Skip to content

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 transaction

4. 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
SituationComportement
App installéeDeep link : app://qr?token=...
App non installéeRedirection : 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}
SegmentContenuTaille
kidKey Identifier (identifie la clé dans Redis/BDD)16 octets → 22 chars b64url
ivVecteur d'initialisation AES-GCM12 octets → 16 chars b64url
ciphertext+authTagPayload chiffré + tag d'authentification GCMvariable

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)

json
{
  "v": 1,
  "uid": "uuid-du-wallet",
  "type": "p2p",
  "ts": 1772190427,
  "nonce": "a3f9c2b1d0e4f5a6b7c8d9e0f1a2b3c4",
  "lot": 1772180000
}

Description des champs

ChampDescriptionType
vVersion du format (actuellement 1)number
uidUUID du wallet (= auth.users.id)string UUID
typeType de transactionstring (voir tableau)
tsTimestamp Unix de génération (secondes)number
nonceValeur aléatoire 16 octets (32 hex chars)string
lotLast Online Timestamp — dernier heartbeat confirménumber

Types de transactions supportés

TypeDescription
p2pTransfert utilisateur à utilisateur
cash_inAgent crédite un client
cash_outAgent remet du cash à un client
merchantPaiement 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-pin

Body :

json
{
  "countryCode": "242",
  "phoneNumber": "XXXXXXXX",
  "pin": "123456"
}

Étape 2 — Génération côté backend

typescript
// 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/BDD
  • deviceKey : clé AES-256 aléatoire 32 octets
  • Persistance : Redis (cache rapide) + table auth.qr_device_keys (durabilité)

Étape 3 — Réponse d'authentification

json
{
  "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

typescript
// 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_kidkid hex (32 chars)
@nex:qr_device_keydeviceKey hex (64 chars)
@nex:qr_user_idUUID 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.

typescript
// 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 de crypto.subtle nécessaire)

10. Rotation du QR

Le QR est régénéré automatiquement côté client.

typescript
// 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/resolve

Body :

json
{
  "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)

CoucheRôle
RedisCache 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_EXPIRED

Le 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

typescript
// 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

MesureDétail
Rate limitingÀ appliquer sur POST /qr/resolve
Rotation du device_keyÀ chaque login ou changement d'appareil
TTL strict300 secondes serveur, 30 secondes client
AES-GCM auth tagToute falsification détectée lors du déchiffrement
UID mismatch checkVé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

FichierRôle
services/auth/src/auth/phone-auth.service.tsGénération de kid + deviceKey au login
services/auth/src/auth/entities/qr-device-key.entity.tsEntité TypeORM (auth.qr_device_keys)
services/auth/src/auth/qr-device-key.service.tsCRUD du device key (Redis + BDD)
services/auth/src/auth/qr-validation.service.tsDéchiffrement et validation complète du token
services/auth/src/auth/auth.controller.tsEndpoint POST /auth/login/phone-pin

Mobile Client (apps/mobile-client)

FichierRôle
core/services/auth.tsLogin et stockage du kid + deviceKey
core/services/storage.tsConstantes STORAGE_KEYS
store/qrCodeStore.tsGénération locale du QR + rotation

Mobile Pro (apps/mobile-pro)

FichierRôle
core/services/auth.tsMême flow que mobile-client
store/qrCodeStore.tsMê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                               │
└─────────────────────────────────────────────────────────────────┘

NxPay — Plateforme fintech CEMAC