Générer une adresse bitcoin "from scratch" avec Node.JS

(publié )

Intro

La méthode habituelle de génération d'adresses bitcoin, implémentée par la majorité des portefeuilles courants, consiste à d'abord générer une clé "racine" (aka "seed", souvent encodée en une suite de mots selon les spécifications du BIP39), et d'utiliser cette clé racine pour dériver (selon une courbe elliptique ECDSA et un chemin prédéfini, pouvant être différent selon les portefeuilles) des paires de clés privées et publiques.

Mais aujourd'hui pour l'explication, nous allons nous limiter à la génération d'une seule addresse, de façon aléatoire et générer l'adresse publique qui lui correspond.

Ce qu'il faut comprendre

Les clés publiques (adresse de réception) et clés privées de Bitcoin fonctionnent selon les principes de la cryptographie asymétrique. C'est-à-dire que la clé publique est générée à partir de la clé privée, mais que l'inverse est impossible. Pour cela, on utilise dans le cas présent une courbe élliptique (plus précisément la courbe secp256k1) qui permet de générer une clé publique à partir d'une clé privée par projection. Pour ce faire, on utilise un point de départ (appelé générateur) sur la courbe, et on va multiplier ce point par la clé privée pour obtenir la clé publique (adresse) correspondante.

Première étape : Génération de la clé privée au format WIF (Wallet Import Format)

C'est quoi ce truc ? Le WIF est tout simplement un format de clé privée, plus simple à copier, intégrant le réseau et permettant de vérifier l'intégrité des données que constituent la clé. Ce format est utilisé et compris par la plupart des portefeuilles Bitcoin actuels.

Génération de la clé brute

Alors c'est parti, on commence directement par la génération aléatoire (en découvrant au passage que générer aléatoirement des bits et bien ça n'est pas si simple !) de 256 bits qui constitueront la base de notre clé, pour cela, on va faire confiance à la lib crypto de NodeJS. Ces 256 bits devront produire une valeur inférieure à la valeur maximale générée par l'algorithme de courbe elliptique ECDSA pour rester dans les normes (rappelez-vous, on est censés dériver une clé qui sert de seed...) : cette valeur étant fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364140 en héxadécimal, on va juste vérifier que la valeur générée convient, pour plus de détails se référer aux détails sur l'algorithme ECDSA dans le lien de la partie précédente.

// Fonctions cryptographiques natives de NodeJS
const crypto = require('crypto');

// Valeur maximale sur 256bits que peut valoir une clé privée Bitcoin
const MAX_KEY = Buffer.from('fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364140', 'hex');

// On génère des bits aléatoires
let rawKey = crypto.randomBytes(32);
// Et si par un grand hasard, on tombe sur une clé qui dépasse la valeur max
// ...on en génère une nouvelle ! ¯\_(ツ)_/¯
while (Buffer.compare(rawKey, MAX_KEY) !== -1) {    
  rawKey = crypto.randomBytes(32);
}

En soi, à cette étape, nous avons déjà une clé privée utilisable, et nous sommes capables de générer l'adresse Bitcoin correspondante. Cependant, nous allons y ajouter quelques informations et la convertir afin de la rendre lisible par la plupart des portefeuilles bitcoin actuels.

Formatage de la clé

Pour respecter le format WIF, la clé privée sera stockée sera encadrée par l'identifiant du réseau et un hash de vérification, puis encodés en base58, sous la forme :

+-------------------+----------------------------------+-----------------------+
| Id Réseau (8bits) | Clé privée (256bits aléatoires)  | Vérification (32bits) |
+-------------------+----------------------------------+-----------------------+

Identifiant du réseau à utiliser

Nous allons donc ajouter au début de la clé une valeur servant à indiquer quelle version du réseau utiliser : ici 0x80 (en hexadécimal) signale que celle-ci sera utilisée sur la version mainnet du réseau. Pour générer la même clé sur la version "testnet", il faudra utiliser 0xef.

const keyWithNetwork = Buffer.concat([Buffer.from('80', 'hex'), rawKey]);

Génération et ajout du checksum (vérification de la clé)

Un checksum est le résultat d'une fonction de calcul simple qui doit permettre de vérifier que les données finales correspondent mathématiquement aux données initiales (par exemple pour vérifier après un transfert réseau que tout est en ordre).

Pour cela, on va ajouter une partie du hash de la clé à la fin de celle-ci (les 8 premiers caractères). Cela permettra de réitérer facilement la même opération une fois la chaine finale reçue, c'est-à-dire, prendre les 8 derniers caractères, et les comparer à un nouveau hash de la clé pour s'assurer que la clé est bien valide.

La clé est donc hashée deux fois avec l'algorithme SHA256, le but du double hash étant de réduire la possibilité de collision) présente lors de la première itération.

// Double Hash SHA256 de la clé avec préfixe du réseau pour création du checksum
const firstHash = crypto.createHash('sha256').update(keyWithNetwork).digest();
const secondHash = crypto.createHash('sha256').update(firstHash).digest();

// On ne prend que les 8 premiers caractères hexa du hash final
const checkSum = secondHash.toString('hex').slice(0, 8);

Le format WIF : encodage "lisible" de la clé

Pour terminer, l'ensemble de la clé (réseau + clé + checksum) seront encodés en base58.

Pourquoi utiliser la base58 ? Pour avoir une représentation lisible (sans caractères spéciaux), la moins longue possible (l'alphabet de cette base comprenant au total des 58 caractères suivants : 123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz) et ne portant pas à confusion (les O et 0 sont supprimés, ainsi que le l - L minuscule - qui peut être confondu avec le chiffre 1).

Et voilà ! On dispose maintenant de notre clé au format WIF (Wallet Import Format), qui, comme son nom l'indique, est donc utilisable sur n'importe quel wallet compatible (Electrum par exemple) !

Pour résumer, voici le code final de génération de notre clé privée au format WIF :

const crypto = require('crypto');
const b58 = require('b58');

const MAX_KEY = Buffer.from('fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364140', 'hex');

let rawKey = crypto.randomBytes(32);
while (Buffer.compare(rawKey, MAX_KEY) !== -1) {    
  rawKey = crypto.randomBytes(32);
}

const keyWithNetwork = Buffer.concat([Buffer.from('80', 'hex'), rawKey]);

const firstHash = crypto.createHash('sha256').update(keyWithNetwork).digest();
const secondHash = crypto.createHash('sha256').update(firstHash).digest();
const checkSum = secondHash.toString('hex').slice(0, 8);

const finalKey = Buffer.from(keyWithNetwork.toString('hex') + checkSum, 'hex');
const wif = b58.encode(finalKey);

console.log(`Clé WIF (Wallet Import Format) : ${wif}`);

Seconde étape : Générer l'adresse publique correspondante

Maintenant que nous avons notre clé privée, nous allons générer l'adresse Bitcoin publique qui lui correspond. Cette adresse est celle que vous donnez aux autres pour recevoir des bitcoins.

Génération de la clé publique avec ECDSA

On utilise la courbe elliptique secp256k1 pour dériver la clé publique à partir de notre clé privée. La clé publique existe en deux formats : compressée et non compressée.

Non compressée (520 bits) : contient les coordonnées complètes (x, y) sur la courbe, préfixée par 0x04

+------+-----------------------------------+-----------------------------------+
| 0x04 |  X (256 bits)                     |  Y (256 bits)                     |
+------+-----------------------------------+-----------------------------------+

Compressée (264 bits) : ne contient que la coordonnée x et un bit indiquant la parité de y. Puisque pour chaque valeur de x sur la courbe elliptique secp256k1, il n'existe que deux valeurs possibles de y (une positive et une négative, symétriques par rapport à l'axe x), on peut reconstruire y en ne stockant que x et un indicateur de direction. Le préfixe 0x02 indique que y est pair (sens descendant de la courbe), 0x03 indique que y est impair (sens ascendant).

+--------------+---------------------------+
| 0x02 ou 0x03 |  X (256 bits)             |
+--------------+---------------------------+

Pourquoi le format compressé est devenu le standard ?

Le format compressé a été introduit plus tard dans l'évolution de Bitcoin et présente plusieurs avantages :

Depuis 2012-2013, les nouveaux wallets génèrent par défaut des clés compressées. Les anciennes adresses non compressées fonctionnent toujours mais sont devenues minoritaires.

const EC = require('elliptic').ec;
const bitcoinCurve = new EC('secp256k1');

// Génération de la paire de clés à partir de la clé privée
const keyPair = bitcoinCurve.keyFromPrivate(rawKey);

// Récupération de la clé publique en format compressé
const publicKey = Buffer.from(keyPair.getPublic().encodeCompressed());

Transformation en adresse Bitcoin

La clé publique en elle-même n'est pas utilisable directement. Il faut la transformer en adresse Bitcoin via plusieurs étapes de hashage. Chaque étape a sa raison d'être :

1. SHA256 de la clé publique

On commence par hasher la clé publique avec SHA256. Cela permet une première transformation cryptographique unidirectionnelle.

2. RIPEMD-160 du résultat (obtention du "pubkey hash")

Le hash SHA256 produit 256 bits (32 bytes), mais on le compresse ensuite avec RIPEMD-160 qui produit 160 bits (20 bytes). Pourquoi cette double opération ?

3. Ajout du préfixe réseau (0x00 pour mainnet)

Ce byte ajouté en préfixe indique le type d'adresse et le réseau :

Cela permet aux wallets de détecter automatiquement si une adresse est valide pour le réseau utilisé et d'éviter d'envoyer des fonds sur le mauvais réseau.

4. Calcul du checksum (double SHA256, on garde les 4 premiers bytes)

Le checksum est une sécurité contre les erreurs de frappe ou de copie. En effectuant un double SHA256 du payload (préfixe + hash) et en gardant les 4 premiers bytes, on obtient une signature unique des données. Lors de la validation d'une adresse, on peut recalculer ce checksum et vérifier qu'il correspond : si une seule lettre de l'adresse est modifiée, le checksum ne correspondra plus. Cela évite d'envoyer des bitcoins à une adresse invalide ou corrompue.

5. Encodage final en Base58Check

Comme pour la clé privée WIF, on encode le tout en Base58. Le résultat final est une adresse Bitcoin du type 1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa.

const crypto = require('crypto');

// 1. SHA256 de la clé publique
const sha256Hash = crypto.createHash('sha256').update(publicKey).digest();

// 2. RIPEMD-160 du hash SHA256
const ripemd160Hash = crypto.createHash('ripemd160').update(sha256Hash).digest();

// 3. Ajout du préfixe réseau (0x00 = mainnet)
const networkByte = Buffer.from('00', 'hex');
const payload = Buffer.concat([networkByte, ripemd160Hash]);

// 4. Calcul du checksum (double SHA256)
const firstSHA = crypto.createHash('sha256').update(payload).digest();
const secondSHA = crypto.createHash('sha256').update(firstSHA).digest();
const checksum = secondSHA.slice(0, 4);

// 5. Combinaison payload + checksum et encodage Base58
const addressBytes = Buffer.concat([payload, checksum]);
const address = b58.encode(addressBytes);

console.log(`Adresse Bitcoin : ${address}`);

Code complet : génération clé privée + adresse publique

Voici le code final qui génère une paire clé privée (WIF) / adresse publique Bitcoin :

const crypto = require('crypto');
const b58 = require('b58');
const EC = require('elliptic').ec;

// Initialisation de la courbe elliptique Bitcoin
const bitcoinCurve = new EC('secp256k1');
const MAX_KEY = Buffer.from('fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364140', 'hex');

// === GÉNÉRATION CLÉ PRIVÉE ===

// Génération de 256 bits aléatoires
let rawKey = crypto.randomBytes(32);
while (Buffer.compare(rawKey, MAX_KEY) !== -1) {
  rawKey = crypto.randomBytes(32);
}

// Formatage WIF de la clé privée
const keyWithNetwork = Buffer.concat([Buffer.from('80', 'hex'), rawKey]);
const firstHash = crypto.createHash('sha256').update(keyWithNetwork).digest();
const secondHash = crypto.createHash('sha256').update(firstHash).digest();
const checkSum = secondHash.toString('hex').slice(0, 8);
const finalKey = Buffer.from(keyWithNetwork.toString('hex') + checkSum, 'hex');
const wif = b58.encode(finalKey);

console.log(`Clé privée WIF : ${wif}`);

// === GÉNÉRATION ADRESSE PUBLIQUE ===

// Dérivation de la clé publique via ECDSA
const keyPair = bitcoinCurve.keyFromPrivate(rawKey);
const publicKey = Buffer.from(keyPair.getPublic().encodeCompressed());

// Hashage de la clé publique
const sha256Hash = crypto.createHash('sha256').update(publicKey).digest();
const ripemd160Hash = crypto.createHash('ripemd160').update(sha256Hash).digest();

// Ajout préfixe réseau et checksum
const payload = Buffer.concat([Buffer.from('00', 'hex'), ripemd160Hash]);
const checksumHash1 = crypto.createHash('sha256').update(payload).digest();
const checksumHash2 = crypto.createHash('sha256').update(checksumHash1).digest();
const addressChecksum = checksumHash2.slice(0, 4);

// Encodage Base58Check final
const addressBytes = Buffer.concat([payload, addressChecksum]);
const address = b58.encode(addressBytes);

console.log(`Adresse publique : ${address}`);

⚠️ Avertissement de sécurité

Ce code est uniquement à but éducatif. Pour une utilisation en production ou la gestion de vrais fonds :

La sécurité de vos bitcoins dépend de la qualité de génération et du stockage de votre clé privée. Celui qui possède la clé privée possède les bitcoins.