Portefeuilles intégrés
Chaque citoyen dispose d'un portefeuille Ethereum créé, chiffré et conservé par le backend. Le citoyen n'installe aucune extension et ne voit jamais sa clé privée : c'est un portefeuille dit custodial, dont la Ville est dépositaire. Le code est dans backend/src/blockchain/wallet.service.ts et le chiffrement dans backend/src/utils/crypto.util.ts. Les choix de sécurité et leurs limites sont discutés sur la page Clés des portefeuilles.
Création
createCustodialWallet() appelle Wallet.createRandom() d'ethers v6, puis renvoie :
| Champ | Contenu |
|---|---|
address |
Adresse du portefeuille, en minuscules (wallet.address.toLowerCase()) |
encryptedKey |
Clé privée chiffrée par encryptText(wallet.privateKey) |
L'adresse est toujours stockée en minuscules dans User.walletAddress. Tout le code qui compare des adresses (indexeur, file d'actions) met lui aussi les adresses en minuscules avant de chercher en base.
Trois endroits créent un portefeuille :
| Appelant | Moment |
|---|---|
createVerifiedUser de backend/src/api/auth/auth.services.ts |
Inscription d'un citoyen, après vérification du code reçu par email |
giftOnchain de backend/src/api/lottery/lottery.services.ts |
Cadeau à une adresse email qui n'a pas encore de compte : le compte du destinataire est créé avec son portefeuille |
ensureWallet (voir plus bas) |
Compte existant sans clé chiffrée |
L'adresse envoyée par le client à la connexion (walletAddress du corps de POST /auth/login) n'est pas utilisée comme portefeuille. Le test d'intégration le vérifie : après connexion avec 0x1234…7890, l'adresse du compte est différente.
Chiffrement AES-256-GCM
La clé de chiffrement est ENCRYPTION_KEY : 64 caractères hexadécimaux, soit 32 octets, chargés une fois par Buffer.from(env.ENCRYPTION_KEY, 'hex'). La valeur par défaut de développement est refusée en production.
encryptText(text) procède ainsi :
- tire un vecteur d'initialisation aléatoire de 12 octets (
crypto.randomBytes(12)) ; - chiffre le texte en UTF-8 avec
aes-256-gcm; - récupère l'étiquette d'authentification de 16 octets (
getAuthTag()) ; - assemble quatre parties séparées par
:.
v2:<iv en base64url>:<étiquette en base64url>:<texte chiffré en base64url>Le préfixe v2 identifie le format. isEncrypted(payload) teste simplement ce préfixe. GCM authentifie le contenu : une clé chiffrée modifiée en base, ou déchiffrée avec une autre ENCRYPTION_KEY, fait échouer decipher.final() au lieu de produire une clé fausse.
decryptText(payload) accepte deux formats :
| Format | Reconnu par | Algorithme |
|---|---|---|
v2:iv:tag:données |
4 parties, la première vaut v2 |
AES-256-GCM |
iv:données en hexadécimal |
2 parties | AES-256-CBC (ancien format, lecture seule) |
Tout autre format lève Invalid encrypted payload format. Le format CBC n'est plus jamais écrit ; il n'est conservé que pour relire d'anciennes valeurs. Les clés de portefeuille sont toutes écrites par encryptText, donc au format v2.
Danger
Perdre ENCRYPTION_KEY rend toutes les clés privées illisibles et les ethers fictifs des portefeuilles définitivement inaccessibles. La changer sans réchiffrer les clés a le même effet. Aucun script de rotation de clé n'existe dans le dépôt.
Stockage
Colonne de User |
Contenu |
|---|---|
walletAddress |
Adresse publique en minuscules, unique |
walletEncryptedKey |
Clé privée au format v2:…, ou NULL pour un compte sans portefeuille intégré |
welcomeGrantTxHash |
Hachage de la transaction de dotation de bienvenue, ou NULL tant qu'elle n'a pas été versée |
GET /api/v1/lottery/wallet renvoie custodial: true quand walletEncryptedKey est renseigné. La clé chiffrée n'est jamais renvoyée par l'API.
Un compte peut exister sans clé chiffrée dans trois cas : comptes créés par le seed, comptes créés par l'indexeur pour une adresse inconnue qui a acheté directement sur le contrat, et comptes créés en mode simulé.
Signataire
signerFor(owner) déchiffre walletEncryptedKey et renvoie un Wallet ethers connecté au fournisseur courant (chainClient.walletSigner). Si la clé est absente, il lève une AppError 409 « Ce compte n'a pas encore de portefeuille BlockLucky ».
La clé déchiffrée n'existe qu'en mémoire, le temps de signer. Elle n'est ni journalisée ni mise en cache. Les appelants sont :
buyTicketsetclaimPrizede la passerelle ;executeChainActionde la file d'actions pourCLAIM_PRIZEetCLAIM_REFUND.
Les transactions du citoyen paient leur gaz depuis son propre portefeuille. Ce gaz est prélevé sur la dotation de bienvenue.
Création à la demande : ensureWallet
ensureWallet(owner) renvoie le compte inchangé s'il a déjà une clé chiffrée. Sinon, il crée un portefeuille et met à jour à la fois walletAddress et walletEncryptedKey.
Attention
ensureWallet remplace l'adresse existante du compte. Un compte du seed comme citoyen@etherbay.fr, dont l'adresse de démonstration n'est pas une adresse Ethereum valide, reçoit ainsi une nouvelle adresse au premier achat ou à la première consultation du portefeuille en mode onchain. Les tickets anciens restent rattachés au compte par userId, mais l'ancienne adresse ne correspond plus à rien.
prepareWallet de la passerelle enchaîne ensureWallet puis la dotation. Il est appelé avant chaque achat, avant chaque cadeau (pour l'expéditeur) et par GET /lottery/wallet.
Dotation de bienvenue
grantWelcomeEthers(owner) envoie au portefeuille du citoyen WELCOME_GRANT_WEI wei depuis le compte opérateur. La valeur par défaut est 500000000000000000, soit 0,5 ETH fictif ; avec le prix de ticket du déploiement local (0,01 ETH), elle couvre quelques tickets et le gaz.
La fonction ne fait rien et renvoie la valeur existante dans ces cas :
- le mode n'est pas onchain ;
welcomeGrantTxHashest déjà renseigné ;WELCOME_GRANT_WEIvaut0(renvoienull).
Sinon, elle suit ce déroulé :
sequenceDiagram
participant A as "Requête A"
participant B as "Requête B"
participant R as "Redis"
participant O as "Compte opérateur"
participant DB as "PostgreSQL"
A->>R: SET wallet:grant-lock:id NX EX 120
R-->>A: OK
B->>R: SET wallet:grant-lock:id NX EX 120
R-->>B: refusé
A->>O: sendTransaction(to, value)
B->>DB: lecture de welcomeGrantTxHash toutes les 500 ms
O-->>A: transaction minée
A->>DB: welcomeGrantTxHash = hash
B-->>B: hash trouvé, retourne le même hashVerrou Redis
Le verrou wallet:grant-lock:<userId> est posé par redis.setIfAbsent avec une durée de vie de 120 secondes (GRANT_LOCK_TTL_SECONDS). Il empêche deux requêtes simultanées (par exemple l'inscription, qui lance la dotation sans l'attendre, puis l'ouverture immédiate de l'écran portefeuille) de verser deux dotations.
En cas de succès, le verrou n'est pas supprimé : il expire seul, et welcomeGrantTxHash empêche ensuite toute nouvelle dotation. En cas d'échec de la transaction, le verrou est supprimé, l'erreur est journalisée et la fonction renvoie null. La dotation sera retentée au prochain prepareWallet.
Attente waitForPendingGrant
La requête qui n'obtient pas le verrou appelle waitForPendingGrant. Elle relit welcomeGrantTxHash en base toutes les 500 ms (GRANT_POLL_MS) pendant au plus 30 secondes (GRANT_WAIT_MS) :
| Situation | Résultat |
|---|---|
| Le hachage apparaît en base | Le hachage est renvoyé |
| Le verrou a disparu sans hachage (échec de l'autre requête) | null |
| 30 secondes écoulées | null |
Un résultat null n'interrompt pas l'achat : la passerelle tente quand même la transaction, qui échoue avec une erreur 402 si le portefeuille n'a pas de quoi payer (voir Passerelle).
Limites connues
- Sans Redis, le verrou est tenu dans la mémoire du process (voir Données Redis). Deux instances de l'API pourraient alors verser chacune une dotation.
- Si la transaction est minée mais que l'écriture de
welcomeGrantTxHashéchoue, le bloccatchsupprime le verrou : une seconde dotation peut être versée plus tard. - L'envoi par le compte opérateur ne bénéficie pas de la reprise sur
NONCE_EXPIREDdesendAndIndex.
Solde
balanceOf(address) renvoie le solde en wei lu par provider.getBalance, ou null en mode simulé ou si le nœud ne répond pas. Les réponses d'achat et de portefeuille le convertissent en ETH (walletBalanceEth, balanceEth).
Effacement RGPD
anonymizeUserAccount de backend/src/api/user/user.repo.ts, appelé à la confirmation d'une demande de suppression, écrit dans la même transaction :
| Colonne | Nouvelle valeur |
|---|---|
walletAddress |
0xdeleted_gdpr_ suivi des 8 premiers caractères de l'identifiant |
walletEncryptedKey |
NULL |
welcomeGrantTxHash |
NULL |
email, twoFactorSecret, twoFactorMethod |
NULL |
twoFactorBackupCodes |
tableau vide |
isTwoFactorEnabled |
false |
deletedAt |
date courante |
Effacer walletEncryptedKey détruit la seule copie de la clé privée. Les ethers fictifs restant sur le portefeuille deviennent inaccessibles, ce qui est sans conséquence financière puisqu'ils n'ont aucune valeur.
Ce que l'effacement ne peut pas faire :
- les transactions passées restent sur la chaîne, avec l'adresse d'origine. L'adresse effacée en base n'est plus reliée à une personne, mais la chaîne est publique et immuable. La page Blockchain et données personnelles traite ce point ;
- si l'indexeur relit plus tard un événement qui mentionne l'adresse d'origine (reconstruction, par exemple), il ne la retrouve plus en base et crée un nouveau compte anonyme pour cette adresse (
userForWallet) ; - un job
CLAIM_PRIZEouCLAIM_REFUNDen attente pour cette adresse ne trouve plus de clé et se termine sans rien faire.