Des recherches sur les mnémoniques multilingues nous ont fait découvrir que Substrate ne respectait pas complètement la norme BIP39.
Au lieu de convertir la phrase de type string du mnémonique par une commande pbkdf2
, ils génèrent un mini-secret à partir de l’entropie. Puis le trousseau depuis le mini-secret.
Pourquoi ne pas respecter complètement le protocole BIP-39 ?
L’équipe de Parity/Substrate explique son choix ici :
Soit, in extenso, en français :
Pourquoi ?
L’approche naturelle consisterait à utiliser la graine de 64 octets générée à partir de la phrase BIP39 et à l’utiliser pour construire la clé. Cette approche, bien que raisonnable et assez simple à mettre en œuvre, implique également que nous devrons hériter de toutes les caractéristiques de la génération de graines. Étant donné que nous rompons déjà la compatibilité avec BIP32 et BIP44 (ce qui est acceptable, car nous n’utilisons plus la courbe Secp256k1), rien ne nous oblige à respecter le mécanisme de génération de graines BIP39 à partir de la phrase mnémonique.
La génération de graines BIP39 a été conçue pour être compatible avec les phrases de portefeuille utilisateur (“brain wallets”) ainsi que pour permettre l’extensibilité aux portefeuilles offrant leurs propres dictionnaires et mécanismes de somme de contrôle. Voici les problèmes liés à ces deux points :
- Les “brain wallets” sont une mauvaise idée :
- Faible entropie : Les humains sont mauvais pour générer une entropie sécurisée, ce qui rend les “brain wallets” très peu sûrs.
- Faux sentiment de sécurité : De nombreux utilisateurs s’appuient sur des phrases mal choisies, ce qui les rend vulnérables aux attaques par dictionnaire ou par force brute, en particulier avec le faible coût informatique des 2048 itérations de PBKDF2.
- Ces “brain wallets” ont causé des pertes d’argent, et aujourd’hui, les fournisseurs de portefeuilles préfèrent s’en tenir à des phrases générées par un générateur de nombres pseudo-aléatoires sécurisé (CSPRNG).
- Les dictionnaires personnalisés sont inutiles :
- Dès le départ, cette fonctionnalité relevait du schéma “vous n’en aurez pas besoin”. Les fournisseurs de portefeuilles (matériels ou logiciels) préfèrent que leurs produits soient compatibles avec d’autres portefeuilles pour permettre aux utilisateurs de migrer sans difficulté.
- La compatibilité est cruciale pour favoriser l’adoption des produits par les utilisateurs.
- Problèmes d’encodage :
- L’encodage UTF-8 NFKD introduit des problèmes potentiels avec les caractères non-ASCII dans certaines langues, comme la normalisation des espaces ou les variations de caractères (par exemple,
ñ
etn
en espagnol). - Les différences d’implémentation peuvent entraîner des incompatibilités, ce qui est problématique pour les fournisseurs de portefeuilles.
Alternatives à la génération de graines basée sur BIP39
Plutôt que de respecter strictement la dérivation de graines selon BIP39 :
- Utilisez directement les tableaux d’octets d’entropie. Les phrases mnémoniques BIP39 sont déjà conçues pour récupérer cette entropie, ce qui permet d’éviter les problèmes d’encodage et de se concentrer sur un mécanisme universel plus simple pour les implémentations de portefeuilles.
- Conservez le mécanisme PBKDF2 et l’option de mot de passe :
- Cela garantit la compatibilité avec les phrases de 12 mots (fréquemment utilisées dans les anciens portefeuilles).
- Les mots de passe permettent des fonctionnalités avancées, comme des portefeuilles-leurres, sans compromettre les principes de sécurité.
Pourquoi ne pas abandonner complètement BIP39 ?
- Interopérabilité entre portefeuilles :
- De nombreux portefeuilles matériels utilisent une seule phrase BIP39 pour gérer plusieurs comptes sur différents réseaux. Un nouveau système incompatible compliquerait leur adoption des futurs protocoles (par exemple, les systèmes basés sur Substrate).
- Familiarité des utilisateurs :
- BIP39 est largement reconnu et utilisé. S’en éloigner nécessiterait de former à nouveau les utilisateurs et risquerait de les décourager.
- Compatibilité avec les outils existants :
- En s’appuyant sur l’infrastructure BIP39 établie (tout en abordant ses faiblesses), les développeurs peuvent intégrer des améliorations sans forcer une refonte complète de l’écosystème.
Recommandations pratiques
- Préserver la compatibilité : Prenez en charge les phrases mnémoniques de 12 et 24 mots pour garantir une compatibilité ascendante, même si le système sous-jacent diverge dans la dérivation ou l’utilisation des clés.
- Simplifier les exigences d’encodage : Mettez l’accent sur l’entropie plutôt que sur l’encodage précis des mots mnémoniques, ce qui réduit les erreurs d’implémentation.
- Conserver les fonctionnalités utiles : Permettez aux utilisateurs avancés de continuer à utiliser des mots de passe pour des couches supplémentaires de sécurité ou des stratégies de leurre.
Conclusion
Bien que BIP39 ne soit pas parfait, sa popularité et l’écosystème qui s’est construit autour de lui rendent difficile son abandon total. En corrigeant ses faiblesses (problèmes d’encodage, dépendance aux “brain wallets”) tout en maintenant sa compatibilité, les développeurs peuvent créer des systèmes plus sécurisés et plus flexibles qui s’intègrent sans heurts avec les outils existants et les habitudes des utilisateurs. Cela permet de concilier innovation et adoption pratique.
Générer l’entropie depuis le mnémonique
Les mots du mnémonique sont stockés dans des listes de 2048 mots (wordlists) où les mots sont numérotés de 0 à 2047. L’entropie est la liste des indices correspondants aux 12 mots du mnémonique.
Typescript
Dans Polkadot.js, le code est le suivant :
export function mnemonicToEntropy (mnemonic: string): Uint8Array {
const words = normalize(mnemonic).split(' ');
if (words.length % 3 !== 0) {
throw new Error(INVALID_MNEMONIC);
}
// convert word indices to 11 bit binary strings
const bits = words
.map((word): string => {
const index = DEFAULT_WORDLIST.indexOf(word);
if (index === -1) {
throw new Error(INVALID_MNEMONIC);
}
return index.toString(2).padStart(11, '0');
})
.join('');
// split the binary string into ENT/CS
const dividerIndex = Math.floor(bits.length / 33) * 32;
const entropyBits = bits.slice(0, dividerIndex);
const checksumBits = bits.slice(dividerIndex);
// calculate the checksum and compare
const matched = entropyBits.match(/(.{1,8})/g);
const entropyBytes = matched && matched.map(binaryToByte);
if (!entropyBytes || (entropyBytes.length % 4 !== 0) || (entropyBytes.length < 16) || (entropyBytes.length > 32)) {
throw new Error(INVALID_ENTROPY);
}
const entropy = u8aToU8a(entropyBytes);
if (deriveChecksumBits(entropy) !== checksumBits) {
throw new Error(INVALID_CHECKSUM);
}
return entropy;
}
Python
Dans le client Python substrate-interface
, la lib binaire Python compilée depuis du code Rust py-bip39-bindings pointe vers un bibliothèque en Rust tiny-bip39 :
fn phrase_to_entropy(phrase: &str, lang: Language) -> Result<Vec<u8>, ErrorKind> {
let wordmap = lang.wordmap();
// Preallocate enough space for the longest possible word list
let mut bits = BitWriter::with_capacity(264);
for (idx, word) in phrase.split(' ').enumerate() {
let word_bits = wordmap.get_bits(word).ok_or(ErrorKind::InvalidWord(idx))?;
bits.push(word_bits);
}
let mtype = MnemonicType::for_word_count(bits.len() / 11)?;
debug_assert!(
bits.len() == mtype.total_bits(),
"Insufficient amount of bits to validate"
);
let mut entropy = bits.into_bytes();
let entropy_bytes = mtype.entropy_bits() / 8;
let actual_checksum = checksum(entropy[entropy_bytes], mtype.checksum_bits());
// Truncate to get rid of the byte containing the checksum
entropy.truncate(entropy_bytes);
let checksum_byte = sha256_first_byte(&entropy);
let expected_checksum = checksum(checksum_byte, mtype.checksum_bits());
if actual_checksum != expected_checksum {
Err(ErrorKind::InvalidChecksum)?;
}
Ok(entropy)
}
Générer le mini-secret depuis l’entropie
L’entropie est ensuite convertie en mini-secret.
Typescript
Dans Polkadot.js, le code est le suivant :
export function mnemonicToMiniSecret (mnemonic: string, password = '', onlyJs?: boolean): Uint8Array {
if (!mnemonicValidate(mnemonic)) {
throw new Error('Invalid bip39 mnemonic specified');
}
if (!onlyJs && isReady()) {
return bip39ToMiniSecret(mnemonic, password);
}
const entropy = mnemonicToEntropy(mnemonic);
const salt = stringToU8a(`mnemonic${password}`);
// return the first 32 bytes as the seed
return pbkdf2Encode(entropy, salt).password.slice(0, 32);
}
Python
Dans le client Python substrate-interface
, la bibliothèque compilé en Rust py-bip39-bindings, se charge de convertir le mnémonique en mini-secret :
#[pyfunction]
pub fn bip39_to_mini_secret(phrase: &str, password: &str, language_code: Option<&str>) -> PyResult<Vec<u8>> {
let salt = format!("mnemonic{}", password);
let language = match Language::from_language_code(language_code.unwrap_or("en")) {
Some(language) => language,
None => return Err(exceptions::PyValueError::new_err("Invalid language_code"))
};
let mnemonic = match Mnemonic::from_phrase(phrase, language) {
Ok(some_mnemomic) => some_mnemomic,
Err(err) => return Err(exceptions::PyValueError::new_err(format!("Invalid mnemonic: {}", err.to_string())))
};
let mut result = [0u8; 64];
pbkdf2::<Hmac<Sha512>>(mnemonic.entropy(), salt.as_bytes(), 2048, &mut result);
Ok(result[..32].to_vec())
}
Générer un trousseau depuis le mini-secret
Typescript
Pour cela, Polkadot.js fait appel à un binaire wasm :
import { sr25519KeypairFromSeed } from '@polkadot/wasm-crypto';
Ce binaire wasm est issu du code Rust suivant :
Python
Dans le client Python substrate-interface, il utilise la bibliothèque py-sr25519-bindings :
/// Returns a public and private key pair from the given 32-byte seed.
///
/// # Arguments
///
/// * `seed` - A 32 byte seed.
///
/// # Returns
///
/// A tuple containing the 32-byte public key and 64-byte secret key, in that order.
#[pyfunction]
pub fn pair_from_seed(seed: Seed) -> PyResult<Keypair> {
let k = MiniSecretKey::from_bytes(&seed.0).expect("32 bytes can always build a key; qed");
let kp = k.expand_to_keypair(ExpansionMode::Ed25519);
Ok(Keypair(kp.public.to_bytes(), kp.secret.to_bytes()))
}
Le binding Python utilise la dépendance Rust Schnorrkel :
/// Derive the `Keypair` corresponding to this `MiniSecretKey`.
pub fn expand_to_keypair(&self, mode: ExpansionMode) -> Keypair {
self.expand(mode).into()
}
/// Expand this `MiniSecretKey` into a `SecretKey` using
/// ed25519-style bit clamping.
///
/// At present, there is no exposed mapping from Ristretto
/// to the underlying Edwards curve because Ristretto involves
/// an inverse square root, and thus two such mappings exist.
/// Ristretto could be made usable with Ed25519 keys by choosing
/// one mapping as standard, but doing so makes the standard more
/// complex, and possibly harder to implement. If anyone does
/// standardize the mapping to the curve then this method permits
/// compatible schnorrkel and ed25519 keys.
///
/// # Examples
///
/// ```compile_fail
/// # #[cfg(feature = "getrandom")]
/// # fn main() {
/// use rand::{Rng, rngs::OsRng};
/// use schnorrkel::{MiniSecretKey, SecretKey};
///
/// let mini_secret_key: MiniSecretKey = MiniSecretKey::generate_with(OsRng);
/// let secret_key: SecretKey = mini_secret_key.expand_ed25519();
/// # }
/// ```
fn expand_ed25519(&self) -> SecretKey {
use sha2::{
Sha512,
digest::{Update, FixedOutput},
};
let mut h = Sha512::default();
h.update(self.as_bytes());
let r = h.finalize_fixed();
// We need not clamp in a Schnorr group like Ristretto, but here
// we do so to improve Ed25519 comparability.
let mut key = [0u8; 32];
key.copy_from_slice(&r.as_slice()[0..32]);
key[0] &= 248;
key[31] &= 63;
key[31] |= 64;
// We then divide by the cofactor to internally keep a clean
// representation mod l.
scalars::divide_scalar_bytes_by_cofactor(&mut key);
#[allow(deprecated)] // Scalar's always reduced here, so this is OK.
let key = Scalar::from_bits(key);
let mut nonce = [0u8; 32];
nonce.copy_from_slice(&r.as_slice()[32..64]);
SecretKey { key, nonce }
}
Je mets ce message en mode wiki si certains veulent ajouter des informations.