Ethereum HD-Wallet in JavaScript (BIP 39): Seed [Teil 3]

In diesem Artikel beschäftigen wir uns damit, wie man aus einer Mnemonic einen Seed erstellt. Hierzu erstellen wir mittels der JavaScript-Bibliothek BIP39 basierend auf der im vorherigen Artikel generierten Mnemonic einen Seed. Anschließend werden wir uns sowohl die Theorie als auch die dahinterstehende Implementierung angucken. Hierbei werden wir zum besseren Verständnis eine vereinfachte Implementierung mittels der “Password-Based Key Derivation Function 2” selbst erstellen und uns dann angucken, welche Fallstricke bei der eigenen Implementierung auftreten können und wieso man deswegen besser die entsprechende JavaScript-Bibliothek BIP39 verwenden sollte.

Aktueller Stand

Wir haben bereits erfolgreich eine Mnemonic mittels der BIP39 Bibliothek erstellt und werden uns in diesem Artikel wieder einen Schritt weiter hin zu unseren konsolenbasierten HD-Walllet bewegen, indem wir hieraus ein Seed mittels dieser Bibliothek erstellen. Um diesem Artikel komplett folgen zu können, empfiehlt es sich die vorherigen Artikel gelesen zu haben oder zumindest die hier verwendete BIP39 Bibliothek mittels npm installiert zu haben.

Die verwendeten BIPs und Bibliotheken zur Abbildung findet ihr am Ende des Artikels.

Wir setzen also voraus, dass bereits, wie hier in dem Beispiel, eine Mnemonic, welche aus 12 Wörter besteht, vorhanden ist. Hieraus können wir mittels einer so genannten genormten “Password-based Key Derivation Function 2” einen 512 Bit Seed erzeugen. Vorerst bekommen wir von dieser Funktion jedoch nichts mit, da wir die JavaScript-Bibliothek BIP39 verwenden und uns deswegen die Implementierungsdetails verborgen bleiben. Wir werden uns jedoch später zum besseren Verständnis genauer hiermit beschäftigen.

Der Seed wird hier im hexadezimalen Format dargestellt und hat 128 Zeichen. Das obige Beispiel veranschaulicht noch einmal kurz wie sich die 128 Zeichen im hexadezimalen System aus dem 512 Bit Seed ergeben. Nehmen wir als Beispiel die Zahl 256, welche binär durch 8 Einsen dargestellt wird. Dies entsprich 8 Bit, also einem Byte. Im hexadezimalen System hingegen kann diese Zahl durch zwei Zeichen dargestellt werden, in diesem Fall “ff”. Demzufolge sind 512 Bit 64 Byte und benötigen im hexadezimalen System 128 Zeichen.

Implementierung mittels der BIP39-Bibliothek

Um nun aus der generierten Mnemonic einen Seed zu erstellen, importieren wir uns zusätzlich die mnemonicToSeed-Funktion aus unserer BIP39-Bibliothek (Zeile 1).

import { generateMnemonic, mnemonicToSeed } from "bip39";

Schauen wir uns erst einmal an, wie diese Funktion in unserer importierten Bibliothek deklariert wurde: Wir sehen, dass diese Funktion als ersten Parameter eine Mnemonic als String erwartet. Dieser Parameter ist im Gegensatz zu dem darauffolgenden Parameter nicht optional, sondern muss immer angegeben werden. Zusätzlich können wir noch ein Passwort angeben, welches aber in der Regel leer bleibt. Wir gucken uns später noch an, welche Auswirkungen es hätte, wenn wir hier zusätzlich ein Passwort festlegen würden.

export declare function mnemonicToSeed(mnemonic: string, password?: string): Promise<Buffer>;

Ferner wird erkenntlich, dass diese Funktion uns eine Promise zurückliefert. Um diese Funktion nun zu verwenden, legen wir als Erstes wieder eine Konstante an, in der wir das Ergebnis dieser Funktion oder genauer gesagt, das Ergebnis der zurückgegebenen Promise, speichern werden.

Da wir eine Promise zurück geliefert bekommen, verwenden wir an dieser Stelle das Schlüssel Wort await, welches bewirkt, dass so lange gewartet wird, bis die durch die aufgerufene mnemonicToSeed-Funktion zurückgelieferte Promise aufgelöst wurde. Danach tippen wir noch den Namen der Funktion ein und übergeben unsere Mnemonic, aus welcher der Seed abgeleitet werden soll (Zeile 7).

import { generateMnemonic, mnemonicToSeed } from "bip39";

const mnemonic = generateMnemonic();
console.log(mnemonic);

try {
    const seed = await mnemonicToSeed(mnemonic);  
    console.log(seed.toString('hex'))
} catch (error) {
    console.log(error);
}    

Das Ganze können wir uns nun mittels console.log ausgeben lassen, jedoch müssen wir hier noch eine kleine Anpassung vornehmen, da unser Seed hier als Puffer-Objekt vorliegt. Wir konvertieren diesen mittels der toString-Funktion einfach in die hexadezimale Darstellungsform und lassen uns diesen ausgeben (Zeile 8).

Zusätzlich haben wir noch einen try-catch-Block hinzugefügt, um evt. auftretende Fehler auszugeben.

Da die verwendete Bibliothek zur Erstellung eines privaten Master-Keys jedoch einen Seed im Puffer-Format erwartet, ist ergänzend noch zu sagen, dass wir diese Anpassung an dieser Stelle hier nur vornehmen, damit die Daten in einem etwas besser lesbareren Format angezeigt werden.

Gucken wir uns nun jedoch noch einmal genauer an, wie die verwendete Bibliothek funktioniert.

Hinweis: Der Folgende Abschnitt dient lediglich dem besseren Verständnis der verwendeten Bibliothek und muss zum Verständnis der weiteren Implementierung nicht zwingend durchgearbeitet werden.

Weiterführende Theorie und Praxis

Haben wir eine Mnemonic also bspw. 12 Wörtern generiert, können wir nun mittels Schlüsselstreckung einen 512 Bit Seed über die genormte Password-Based Key Derivation Function 2, kurz PBKDF2, erstellen.

Die Password-Based Key Derivation Function 2 benötigt als Eingabeparameter unter anderem die Mnemonic, sowie einen Salt-Wert, welcher die Verwendung von Rainbow Tables erschwert. Gem. BIP 39 lautet dieser Salt-Wert einfach “mnemonic”, kann jedoch um ein optionales Passwort ergänzt werden, um die Sicherheit weiter zu erhöhen. Innerhalb der Password-Based Key Derivation Function 2 wird dann der HMAC-SHA512-Algorithmus gem. BIP 39 2048 mal angewandt.

Diese Anzahl der Wiederholungen, sowie der Hash-Algorithmus und die Größe des Seeds, werden der Password-Based Key Derivation Function 2, wie wir später noch sehen werden, ebenfalls als Parameter übergeben. Aus übersichtlichkeitsgründen wurden diese jedoch auf der folgenden Folie nicht eingeblendet.

Am Schluss erhalten wir dann einen 512 Bit großen Seed, sodass die Password-Based Key Derivation Function 2 unsere Mnemonic, welche aus 12 – 24 Wörtern besteht, auf einen 512 Bit großen Seed gestreckt hat.

Da der Seed 512 Bit groß ist, existieren 2 hoch 512 mögliche Seeds, sodass es nahezu unmöglich ist, per Brute Force einen Seed zu finden, der bereits verwendet wird. Durch die PBKDF2, welche den HMAC-SHA512-Algorithmus gem. BIP 39 insgesamt 2048 mal anwendet, wird es zudem für einen Angreifer aufgrund der erhöhten Rechenleistung unattraktiv sämtliche Kombinationen von Mnemonics durchzuprobieren, da dieser für jede Ableitung eines Seeds die entsprechende Rechenleistung aufbringen muss.

Ihr könnt das Ganze auch noch einmal selbst im BIP 39 in dem Abschnitt „From mnemonic to seed“ nachlesen.

Eigene Implementierung zum besseren Verständnis (in der Praxis nicht empfehlenswert)

Bevor wir hiermit anfangen, ist jedoch anzumerken, dass von einer eigenen Implementierung in der Praxis abgesehen werden sollte, da sich hier, wie wir später noch bei einer kurzen Analyse der verwendeten BIP39 Bibliothek sehen werden, Unsauberkeiten einschleichen können, die im schlimmsten Fall zum Verlust der Kryptowährung führen können. Deswegen sei an dieser Stelle noch einmal explizit gesagt, dass die Folgende Implementierung lediglich dem besseren Verständnis dient – wir werden selbstverständlich auch in  den folgenden Artikel nicht mit der eigenen Implementierung fortfahren, sondern weiterhin die bip39-Bibliothek verwenden.

Zunächst importieren wir uns die pbkdf2-Funktion aus dem NodeJS-Crypto-Module.

import { pbkdf2 } from 'crypto';

Wenn wir diese Funktion eingeben, können wir uns die Syntax in VS-Code anzeigen lassen, indem wir den Mauszeiger darüber bewegen. Hier fällt direkt auf, dass die pbkdf2 eine etwas veraltete Implementierung mittels Callbacks verwendet. Eine Callback-Funktion, zu Deutsch Rückruf-Funktion, ist im Prinzip lediglich eine Funktion, die einer anderen Funktion als Parameter übergeben wird und von dieser Funktion dann unter bestimmten Bedingungen mit entsprechenden Parametern aufgerufen wird.

Wir verwenden also diese Funktion wie folgt: Als ersten Parameter übergeben wir unsere Mnemonic, welche wir zuvor generiert haben, als zweiten Parameter möchte die Funktion das so genannte Salt, welches in unserem Fall, wie wir in der Theorie bereits gesehen haben, dem String „mnemonic“ entspricht. Sodann geben wir die Häufigkeit der Anwendung unserer Hash-Funktion im Schlüsselstreckungsprozess an. Auch hier müssen wir wieder den durch das BIP 39 vorgegebenen Wert 2048 eingeben. Danach geben wir die entsprechende Größe des zu generierenden Keys in Byte an (also 512 Bit / 8 ergibt 64 Byte). Im nächsten Parameter geben wir die zu verwendende kryptografischen Hashfunktion „sha512“ an.  Zum Schluss müssen wir die entsprechende Callback-Funktion übergeben und gleichzeitig implementieren. Unsere Callback-Funktion bekommt als Parameter einen Error und den Key übergeben. Sofern ein Error entsteht, wird dieser auf der Konsole ausgegeben, ansonsten geben wir den entsprechenden Key zurück, welchen wir noch kurz in die hexadezimale Darstellungsform konvertieren. Wenn wir die Funktion ausführen, sollten wir dasselbe Ergebnis erhalten, wie von der Bibliothek.

pbkdf2(mnemonic, "mnemonic", 2048, 64, 'sha512', (err, key) => {
    if (err) {
        console.log(err);
    } else {
        console.log(key.toString('hex'));
    }
})

Da die Arbeit mit Callbacks zu unleserlichen Code führen kann, machen wir uns hieraus noch eine Promise-Objekt. Ein Promise-Objekt ist vereinfacht ausgedrückt, ein Versprechen, welches bei erfolgreicher Ausführung eines asynchronen Codes den entsprechenden Rückgabewert liefert. Tritt ein Fehler auf, so wird ein Fehler-Objekt anstatt des zu erwartenden Wertes zurückgegeben.

const promisePBKDF2 = () => {
    return new Promise((resolve , reject) => {
        pbkdf2(mnemonic,"mnemonic", 2048, 64, 'sha512', (err, key) => {
            if(err) {
                return reject(err);
            } else {
                return resolve(key.toString("hex"))
            }
        })
    })
}

Wir schreiben uns also kurz eine neue Funktion, geben ein neues Promise-Objekt zurück, welches, wie üblich eine entsprechende Funktion mit den Parametern „resolve“ und „reject“ übergeben bekommt und führen nachfolgend in dieser Funktion unsere zuvor implementierte pbkdf2 aus. Hierbei müssen wir nur an den entsprechenden Stellen anstatt console.log ein reject – sofern es fehlschlägt zurückgeben – und ein resolve, falls unsere Funktion erfolgreich ausgeführt werden kann. Jetzt können wir diese Funktion wie eine normale Promise in unserem try-catch-Block mittels await aufrufen (Zeile 23 und 24).

import {generateMnemonic, mnemonicToSeed} from 'bip39';
import {pbkdf2} from 'crypto';

const mnemonic = generateMnemonic();
console.log(mnemonic);

const promisePBKDF2 = () => {
    return new Promise((resolve, reject) => {
        pbkdf2(mnemonic, 'mnemonic', 2048, 64, 'sha512', (err, key) => {
            if(err) {
                reject(err);
            } else {
                resolve(key.toString('hex'));
            }
        })
    })
}

try {
    const seed = await mnemonicToSeed(mnemonic);
    console.log(seed.toString('hex'));

    const seed2 = await promisePBKDF2();
    console.log(seed2.toString('hex'));
} catch (error) {
    console.log(error);
}

Schauen wir uns abschließend noch einmal kurz an, wie das Ganze in der JavaScript Bibliothek implementiert wurde. Hier wurde in einem Commit vom 07.02.23 ein Wechsel hin zur noble crypto Bibliothek vollzogen, sodass diese Bibliothek nun die asynchrone pbkdf2Async-Funktion verwendet. Man sieht in diesem Commit, dass die Implementierung vor dem Wechsel unserer eigenen Implementierung sehr ähnelte.

function mnemonicToSeed(mnemonic, password) {
    const mnemonicBuffer = Uint8Array.from(Buffer.from(normalize(mnemonic), 'utf8'));
    const saltBuffer = Uint8Array.from(Buffer.from(salt(normalize(password)), 'utf8'));
    return pbkdf2_1.pbkdf2Async(sha512_1.sha512, mnemonicBuffer, saltBuffer, {
        c: 2048,
        dkLen: 64,
    }).then((res) => Buffer.from(res));
}

Die pbkdf2Async-Funktion hat den Vorteil, dass wir hier zu keiner Zeit mit Callbacks arbeiten müssen und das Ziel unserer vorherigen Implementierung quasi direkt über diese Bibliothek erreicht werden kann. Ein kleiner Nachteil ist hier natürlich, dass man sich auf eine weitere Bibliothek verlassen muss.

Interessant ist an dieser Stelle noch die Salt-Funktion, welche die Vorgaben des BIP 39 auf relativ einfache Art umsetzt. Wenn kein extra Passwort gesetzt wird, wird schlicht der String „mnemonic“ zurückgegeben, ansonsten wird ein String zurückgegeben, der eben den String „mnemonic“ mit dem zusätzlichen Passwort miteinander verbindet, sodass für unseren Seed eine zusätzliche Sicherheit gewährleistet werden könnte.

function salt(password) {
    return 'mnemonic' + (password || '');
}

Fallstricke bei der Implementierung des BIP39

Schauen wir uns nun einmal kurz die Fallstricke bei der Implementierung des BIP39 an. Hierzu gehen wir noch einmal kurz in das BIP39 und stellen fest, dass bei der Erstellung eines Seeds von einer Mnemonic noch etwas berücksichtig werden muss:

The wordlist can contain native characters, but they must be encoded in UTF-8 using Normalization Form Compatibility Decomposition (NFKD).

To create a binary seed from the mnemonic, we use the PBKDF2 function with a mnemonic sentence (in UTF-8 NFKD) used as the password and the string „mnemonic“ + passphrase (again in UTF-8 NFKD) used as the salt. The iteration count is set to 2048 and HMAC-SHA512 is used as the pseudo-random function. The length of the derived key is 512 bits (= 64 bytes).

Quelle: BIP 39.

Die Mnemonic sollte also in der NFKD-Normalform vorliegen.

Wenn wir die Erstellung des Seeds mit einem entsprechenden Haltepunkt im Debugger ausführen, stellen wir auch fest, dass hier eine entsprechende Normalisierung der entgegengenommenen Mnemonic mittels der separaten normalize-Funktion vorgenommen wird.

function normalize(str) {
    return (str || '').normalize('NFKD');
}

Diese bewirkt unter anderem, dass Unterschiede in der Codierung von gleichen Inhalten angeglichen werden und dass gleiche Inhalte, welche jedoch aufgrund einer unterschiedlichen Darstellung eine andere Bedeutung annehmen können, ebenfalls angeglichen werden. Wir gucken uns das anhand eines Beispiels noch einmal genauer an.

Normalisierung

Obgleich es mehrere Normalformen zur Normalisierung von Unicode-Zeichenketten gibt, beschränken wir uns hier auf die vom BIP39 vorgeschriebene und in dieser Bibliothek verwendeten NFKD-Normalform.

Im Unicode-Standard kann ein Ä bspw. als „Ä“ oder als „A“ mit zwei Punkten über den Buchstaben dargestellt werden. Es existieren also für den Buchstaben Ä zwei unterschiedliche Codierungen, die am Ende denselben Buchstaben ausgeben. Die entsprechenden Codepoints zur Darstellung der Zeichen könnt ihr der obigen Folie entnehmen. Direkt darunter werden die Codepoints noch einmal in der String-Notation für JavaScript dargestellt, da wir diese gleich auch in einem Beispiel in JavaScript anwenden werden.

Bei der hier verwendeten Normalform werden also in diesem Beispiel, die Codepoints auf der linken Seite in die Unicode-Codepoints auf der rechten Seite umgewandelt bzw. zerlegt.

Ein weiteres Beispiel ist die unterschiedliche Darstellung von Zeichen. So hat eine hochgestellte 2 zwei eine andere Darstellung als eine normale Zwei. Obwohl es sich in beiden Fällen um 2 handelt, haben diese zwei Zeichen natürlich eine unterschiedliche Zeichencodierung. Die entsprechenden Codepoints in der JavaScript String-Notation sehen wir unter den Zahlen. Bei der hier gewählten Normalform wird nach der Normalisierung aus der hochgestellten Zwei eine normale Zwei.

Implementierungsbeispiel NFKD

Wir gucken uns diese exemplarisch gewählten Beispiele noch einmal im Code an. Hierzu habe ich einen kleinen separaten, vom Projekt unabhängigen Code-Schnipsel erstellt, welcher das Ganze noch einmal kurz verdeutlicht.

// String to Unicode 

function stringToUnicode(myString) {
    let result = '';
    for(let i = 0; i <myString.length; i++ ) {
        let hex = myString.codePointAt(i).toString(16);
        let unicode = "\\u" + "0000".substring(0, 4 - hex.length) + hex.toUpperCase();
        result += unicode;
    }
    return result;
}

// Beispiel: Test der Funktion

console.log('-'.repeat(100))
console.log('Das Wort \'test\' hat folgende Codepoints: ' + stringToUnicode('test'))
console.log('Test der Codepoints: ' + '\u0074\u0065\u0073\u0074')
console.log('-'.repeat(100))

// Beispiel: Buchstabe Ä

console.log('Der Buchstabe Ä hat folgenden Codepoint: ' + stringToUnicode('Ä'))

const letterCodepoint1 = '\u00C4'
console.log('Darstellung über den ersten Codepoint: ' + letterCodepoint1)

const letterCodepoint2 = '\u0041\u0308'
console.log('Darstellung über den zweiten Codepoint: ' + letterCodepoint2)

console.log('Handelt es sich um dasselbe Zeichen? ' + (letterCodepoint1 === letterCodepoint2))
console.log('-'.repeat(100))

// Normalisierung

const normalizedLetter = letterCodepoint1.normalize('NFKD')

console.log('Codepoints: ' + stringToUnicode(normalizedLetter))
console.log('Handelt es sich jetzt um dasselbe Zeichen? ' + (normalizedLetter === letterCodepoint2))
console.log('-'.repeat(100))

// Zweites Beispiel:

console.log('Die hochgestellte Zwei hat folgenden Codepoint: ' + stringToUnicode('²'))
console.log('Die Zahl zwei hat folgenden Codepoint: ' + stringToUnicode('2'))
console.log('-'.repeat(100))

const squared = '\u00B2'
const normalTwo = '\u0032'
const normalizedSquared = squared.normalize("NFKD")

console.log('Hochgestellte Zwei: ' + squared)
console.log('Normale Zwei: ' + normalTwo)
console.log('Normalisierte hochgestellte Zwei: ' + normalizedSquared)
console.log('Normalisierte hochgestellte Zwei entspricht normaler Zwei: ' + (normalizedSquared === normalTwo))
console.log('-'.repeat(100))

Der obige Code erzeugt folgende Ausgabe, auf die wir im folgenden noch kurz genauer eingehen werden:

----------------------------------------------------------------------------------------------------
Das Wort 'test' hat folgende Codepoints: \u0074\u0065\u0073\u0074
Test der Codepoints: test
----------------------------------------------------------------------------------------------------
Der Buchstabe Ä hat folgenden Codepoint: \u00C4
Darstellung über den ersten Codepoint: Ä
Darstellung über den zweiten Codepoint: Ä
Handelt es sich um dasselbe Zeichen? false
----------------------------------------------------------------------------------------------------
Codepoints: \u0041\u0308
Handelt es sich jetzt um dasselbe Zeichen? true
----------------------------------------------------------------------------------------------------
Die hochgestellte Zwei hat folgenden Codepoint: \u00B2
Die Zahl zwei hat folgenden Codepoint: \u0032
----------------------------------------------------------------------------------------------------
Hochgestellte Zwei: ²
Normale Zwei: 2
Normalisierte hochgestellte Zwei: 2
Normalisierte hochgestellte Zwei entspricht normaler Zwei: true
----------------------------------------------------------------------------------------------------

String to Unicode: Ziele 3 – 11

Da JavaScript meines Wissens keine native Funktion zur Umwandlung von Strings in Codespoints der entsprechenden Notation besitzt, sondern lediglich die Methode codePointAt(), welche als Parameter ein Zeichen erwartet, habe ich hier eine kleine Funktion geschrieben, welche uns die Umwandelung besser verdeutlichen kann, indem diese den Codepoint für jeden Buchstaben eines übergebenen Strings in der JavaScript-Notation zurückgibt.

Beispiel: Test der Funktion: Zeile 15-18

Übergeben wir bspw. den String “test” so erhalten wir von dieser Funktion die entsprechenden Codepoints. Diesen können wir dann einfach ausgeben, um zu schauen, ob die Funktion richtig funktioniert. Als nächstes erhalten wir wieder das übergebene Wort. Ich habe die Codepoints hier zum besseren Verständnis noch einmal ausgeschrieben.

Beispiel: Buchstabe Ä

Wir lassen uns dann den Codepoint des Buchstaben Ä einmal ausgeben und geben diesen Buchstaben mittels des Codepoints auch wieder auf der Konsole aus. Gleichzeitig stellen wir fest, dass die gleiche Ausgabe auf der Konsole mittels der zwei bereits erwähnten Codepoints möglich ist. Vergleichen wir die beiden Codepoints, stellen wir natürlich fest, dass es sich nicht um exakt die gleichen Zeichen handelt, auch wenn die Ausgabe auf der Konsole für den Endnutzer dieselbe ist.

Normalisierung

Normalisieren wir jedoch den ersten Codepoint in die NFKD-Normalform und vergleichen anschließend die Codepoints, stellen wir fest, dass es sich jetzt um dieselben Codepoints handelt, da der erste Codepoint entsprechend zerlegt wurde und jetzt dem zweiten Codepoint gleicht.

Als zweites Beispiel haben ich eine hochgestellte Zwei gewählt, welche natürlich einen anderen Codepoint hat, als eine normale Zwei, in der entsprechenden Normalform aber ebenfalls angeglichen wird. Wir sehen, dass nach der Normalisierung nur noch eine normale Zwei ausgegeben wird, welche natürlich denselben Codepoint wie die zuvor ausgegebene normale Zwei hat.

Wie Ihr seht, ist es auf jeden Fall sinnvoll, hier keine eigene Implementierung vorzunehmen, sondern eine entsprechende Bibliothek zu verwenden, die diese Fälle auch berücksichtigt.

Im nächsten Artikel beschäftigen wir uns unter anderem damit, wie man aus einem Seed erweiterte Master-Schlüssel erstellt und hieraus Kind-Schlüssel ableitet.

BIPs und verwendete Bibliotheken

BIPs:
[1] https://github.com/bitcoin/bips
[2] https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki
[3] https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki
[4] https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki

Genutzte Bibliotheken:
[5] https://www.npmjs.com/package/bip39
[6] https://www.npmjs.com/package/hdkey
[7] https://www.npmjs.com/package/ethereumjs-util

Nach oben scrollen