BIP39 - это протокол, который описывает, как создать удобочитаемое мнемоническое предложение и как преобразовать эту мнемонику в семя, которое затем может создать криптокошелек HD (BIP32 / BIP44).

Например:

mnemonic:
    talk smoke guess belt become ritual
    powder lyrics annual tomorrow relief witness
passphrase: m3d1um

Создает сид BIP39:

da8fefd74e5ce5cd644aa4f73ef265f80e95e622331039cd33b223f069282347f071740d29bec6aed7e25159bcda9589566dd23152269a49b64490a95f684c34

Запоминать, записывать без ошибок и передавать мнемонику и необязательную парольную фразу намного проще, чем делать то же самое с семенем.

Мнемоника BIP39 сегодня используется во многих программных кошельках, а также в топовых аппаратных кошельках, таких как Trezor и Ledger.

Вы можете прочитать полное описание BIP39 здесь: https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki

Но как разработчик программного обеспечения я лучше понимаю, вижу ли я код. Существует много библиотек, и все они очень сложные, потому что, конечно же, они делают больше, чем просто BIP39. В этой статье я расскажу о создании микробиблиотеки BIP39.

Код, который я предоставлю, будет тщательно прокомментирован и не будет преобразован во многие хорошо названные методы единственной ответственности. Это не мой обычный стиль и не то, что вы увидите в опубликованном репозитории git, это всего лишь первый набросок этих функций.

Открытый интерфейс BIP39 выполняет три функции:

createMnemonic(entropy, wordlist) -> mnemonic
getSeed(mnemonic, passphrase) -> seed
verifyMnemonic(mnemonic, wordlist) -> validity

Это идеальный след микробиблиотеки. Как оказалось, функция getSeed не имеет ничего общего с двумя другими (за исключением некоторых общих тестовых векторов), и это могут быть две микробиблиотеки, но я не хочу разделять BIP.

createMnemonic (энтропия, список слов) - ›мнемоника

Сначала в спецификации предусмотрены предварительные проверки; длина битов в энтропии должна делиться на 32 и находиться в пределах включающего диапазона 128–256 бит.

final int ent = entropyByteCount * 8;
if (ent < 128)
    throw new RuntimeException("Entropy too low, 128-256 bits allowed");
if (ent > 256)
    throw new RuntimeException("Entropy too high, 128-256 bits allowed");
if (ent % 32 > 0)
    throw new RuntimeException("Number of entropy bits must be divisible by 32");

Затем описывается метод получения первых cs бит хеша.

//checksum length (bits)
final int cs = ent / 32;
//mnemonic length (words)
final int ms = (ent + cs) / 11;

Нам нужно добавить первые cs бит хеша в наш байтовый массив энтропии.

//make a copy with space for one more
final byte[] entropyWithChecksum = Arrays.copyOf(entropy, entropy.length + 1);
//hash the original
MessageDigest digest = MessageDigest.getInstance("SHA-256");
final byte[] hash = digest.digest(entropy);
//append the first byte of the hash to the copy
entropyWithChecksum[entropy.length] = hash[0];

Но обратите внимание, я не собираюсь сокращать контрольную сумму, я просто добавляю первый байт целиком. Это потому, что мы собираемся брать 11 байтов за раз, а оставшаяся часть байта в конце все равно будет игнорироваться.

Теперь нам просто нужно обрабатывать этот массив байтов по 11 байтов за раз, что является довольно пугающей перспективой.

Для этого я представил движущееся окно битов по массиву байтов. Он будет иметь смещение до начала окна и длину 11 бит. Окно может занимать 2 или 3 байта из массива байтов в зависимости от смещения.

static int next11Bits(byte[] bytes, int offset) {
    //what's the index of the first byte
    final int skip = offset / 8;

    //how many bits will we need to drop from
    // the end of a 3 byte word?
    final int lowerBitsToRemove = (3 * 8 - 11) - (offset % 8);

    //get the first byte, the 0xff trick is
    // due to Java not having unsigned types
    final int b1 = (int) bytes[skip] & 0xff;

    //second byte
    final int b2 = (int) bytes[skip + 1] & 0xff;

    //third byte, but only if we need it,
    // or we might go out of bounds
    final int b3 = lowerBitsToRemove < 8
            ? ((int) bytes[skip + 2] & 0xff)
            : 0;

    //build up a 3 byte word from the three bytes
    final int firstThreeBytes = b1 << 16 | b2 << 8 | b3;

    //this mask is fixed, it's to keep the last 11 bits
    final int mask = (1 << 11) - 1;

    //drop the last n bits and apply the
    // mask to loose the upper bits
    return firstThreeBytes >> lowerBitsToRemove & mask;
}

Удаление комментариев и небольшое вложение выглядит так:

static int next11Bits(byte[] bytes, int offset) {
    final int skip = offset / 8;
    final int lowerBitsToRemove = (3 * 8 - 11) - (offset % 8);
    return (((int) bytes[skip] & 0xff) << 16 |
            ((int) bytes[skip + 1] & 0xff) << 8 |
            (lowerBitsToRemove < 8
                    ? ((int) bytes[skip + 2] & 0xff)
                    : 0)) >> lowerBitsToRemove & (1 << 11) - 1;
}

Это уродливо, но у него хорошо названный метод, и он хорошо покрыт тестами, так что я могу безопасно провести рефакторинг еще немного позже, если я чувствую, что это нужно.

Итак, теперь у нас есть entropyWithChecksum и способ чтения по 11 бит за раз, все, что осталось, - это прочитать его из списка слов и построить нашу мнемонику.

final StringBuilder sb = new StringBuilder();

for (int i = 0; i < ent + cs; i += 11) {
    final int wordIndex = next11Bits(entropyWithChecksum, i);
    sb.append(wordList.getWord(wordIndex));
    sb.append(" ");
}

//drop the last space
sb.setLength(sb.length() - 1);

return sb.toString();

В разных языках используются разные символы пробела, поэтому нам также необходимо передать их, давайте представим интерфейс для WordList:

public interface WordList {
    String getWord(final int index);
    char getSpace();
}

Всего:

public static String createMnemonic(
        final byte[] entropy,
        final WordList wordList) throws NoSuchAlgorithmException {
    //prechecks
    final int ent = entropy.length * 8;
    if (ent < 128)
        throw new RuntimeException("Entropy too low, 128-256 bits allowed");
    if (ent > 256)
        throw new RuntimeException("Entropy too high, 128-256 bits allowed");
    if (ent % 32 > 0)
        throw new RuntimeException("Number of entropy bits must be divisible by 32");

    //checksum length (bits)
    final int cs = ent / 32;
    //mnemonic length (words)
    final int ms = (ent + cs) / 11;

    //make a copy with space for one more
    final byte[] entropyWithChecksum = Arrays.copyOf(entropy, entropy.length + 1);
    //hash the original
    MessageDigest digest = MessageDigest.getInstance("SHA-256");
    final byte[] hash = digest.digest(entropy);
    //append the first byte of the hash to the copy
    entropyWithChecksum[entropy.length] = hash[0];

    final StringBuilder sb = new StringBuilder();

    for (int i = 0; i < ent + cs; i += 11) {
        final int wordIndex = next11Bits(entropyWithChecksum, i);
        sb.append(wordList.getWord(wordIndex));
        sb.append(wordList.getSpace());
    }
    //drop the last space
    sb.setLength(sb.length() - 1);

    return sb.toString();
}

Это работает для всех английских и японских тестовых векторов, на которые есть ссылки в документе BIP39.

getSeed (мнемоника, кодовая фраза) - ›seed

Теперь мы переходим к поиску семени из мнемоники и ключевой фразы. Вы можете быть удивлены, увидев заголовок этого раздела, не увидев список слов в качестве входных данных для этой функции. Это потому, что мы не идем назад, чтобы найти энтропию, а вместо этого хешируем мнемонику с необязательной парольной фразой для соли.

Чтобы создать двоичное начальное число из мнемоники, мы используем функцию PBKDF2 с мнемоническим предложением (в UTF-8 NFKD), используемым в качестве пароля, и строкой «мнемоника» + парольная фраза (снова в UTF-8 NFKD) в качестве соли. Счетчик итераций установлен на 2048, а HMAC-SHA512 используется как псевдослучайная функция. Длина производного ключа составляет 512 бит (= 64 байта).

Вот и все, что нужно сделать на этот раз.

Первый шаг - нормализовать обе строки.

mnemonic = Normalizer.normalize(mnemonic, Normalizer.Form.NFKD);
passphrase = Normalizer.normalize(passphrase, Normalizer.Form.NFKD);

NFKD - это разложение совместимости, грубо говоря, это означает, что мы упрощаем глифы с целью добраться до чего-то, что мы можем приблизить в UTF-8, без необходимости сохранять точные символы. Например, этому японскому Ka カ половинной ширины соответствует обычная ширина カ.

Теперь нам нужно получить байты UTF-8 для соли с префиксом string"mnemonic".

byte[] salt = ("mnemonic" + passphrase).getBytes("UTF-8");

Для самой мнемоники я оставлю UTF-16

char[] chars = mnemonic.toCharArray();

Теперь нам нужно передать их через функцию PBKDF2 с алгоритмом хеширования HMAC-SHA512, с 2048 итерациями и длиной ключа 512 бит.

PBEKeySpec spec = new PBEKeySpec(chars, salt, 2048, 512);
SecretKeyFactory secretKeyFactory =
    SecretKeyFactory.getInstance("PBKDF2WithHmacSHA512");
byte[] seed = secretKeyFactory.generateSecret(spec).getEncoded();

В целом, это очень короткая функция.

public static byte[] getSeed(String mnemonic, String passphrase) throws Exception {
    mnemonic = Normalizer.normalize(mnemonic, Normalizer.Form.NFKD);
    passphrase = Normalizer.normalize(passphrase, Normalizer.Form.NFKD);

    final char[] chars = mnemonic.toCharArray();
    final byte[] salt = ("mnemonic" + passphrase).getBytes("UTF-8");
    final PBEKeySpec spec = new PBEKeySpec(chars, salt, 2048, 512);
    final SecretKeyFactory secretKeyFactory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA512");
    return secretKeyFactory.generateSecret(spec).getEncoded();
}

Теперь это работает со всеми тестовыми векторами английского и японского языков, на которые есть ссылки в документе BIP39.

Мы оставили мнемонику в собственном формате Java UTF-16, но я думаю, что функция PBKDF2 должна обрабатывать ее как UTF-8, иначе она просто не будет работать. Просто поменяв соль на UTF-16, даже английские векторы не работают.

verifyMnemonic (мнемоника, список слов) - ›validity

Хотя использование мнемоники, не созданной алгоритмом, описанным в разделе «Создание мнемоники», возможно, это не рекомендуется, и программное обеспечение должно вычислять контрольную сумму для мнемонического предложения, используя список слов, и выдавать предупреждение, если оно недействительно.

Итак, в качестве последнего шага, чтобы завершить эту библиотеку, я собираюсь предоставить функцию, которая будет проверять правильность мнемоники по списку слов.

Это отображение слов обратно в entropyWithChecksum, удаление последнего байта и пересчет контрольной суммы. И сравнивая от 4 до 8 бит последнего байта, в зависимости от длины.

private boolean validate(String mnemonic, WordList wordList) {
    //build a map to look up word indexes
    Map<String, Integer> map = new HashMap<>(1 << 11);
    for (int i = 0; i < 1 << 11; i++) {
        map.put(wordList.getWord(i), i);
    }

    //split the mnemonic
    String[] words = mnemonic.split(String.valueOf(wordList.getSpace()));

    //reverse calculate some of the variables from mnemonic generation, ms, ent, cs
    final int ms = words.length;

    final int entPlusCs = ms * 11;
    final int ent = (entPlusCs * 32) / 33;
    final int cs = ent / 32;
    if (entPlusCs != ent + cs)
        throw new RuntimeException("Not a correct number of words");
    byte[] entropyWithChecksum = new byte[(entPlusCs + 7) / 8];

    //look up the words
    int[] wordIndexes = new int[ms];
    for (int i = 0; i < ms; i++) {
        String word = words[i];
        Integer index = map.get(word);
        if (index == null) throw new RuntimeException("Word not found in word list \"" + word + "\"");
        wordIndexes[i] = index;
    }

    //build
    for (int i = 0, bi = 0; i < ms; i++, bi += 11) {
        writeNext11(entropyWithChecksum, wordIndexes[i], bi);
    }

    //strip the last byte
    byte[] entropy = Arrays.copyOf(entropyWithChecksum, entropyWithChecksum.length - 1);
    byte lastByte = entropyWithChecksum[entropyWithChecksum.length - 1];

    //recalculate hash
    byte sha = firstByteOfSha256(entropy);

    //we only want to compare the first cs bits
    byte mask = (byte) ~((1 << (8 - cs)) - 1);

    //if the first cs bits are the same, it's valid
    return ((sha ^ lastByte) & mask) == 0;
}

private void writeNext11(byte[] bytes, int value, int offset) {
    int skip = offset / 8;
    int bitSkip = offset % 8;
    {//byte 0
        byte firstValue = bytes[skip];
        byte toWrite = (byte) (value >> (3 + bitSkip));
        bytes[skip] = (byte) (firstValue | toWrite);
    }

    {//byte 1
        byte valueInByte = bytes[skip + 1];
        final int i = 5 - bitSkip;
        byte toWrite = (byte) (i > 0 ? (value << i) : (value >> -i));
        bytes[skip + 1] = (byte) (valueInByte | toWrite);
    }

    if (bitSkip >= 6) {//byte 2
        byte valueInByte = bytes[skip + 2];
        byte toWrite = (byte) (value << 13 - bitSkip);
        bytes[skip + 2] = (byte) (valueInByte | toWrite);
    }
}

Все это доступно под GPL-3.0 в репозитории здесь https://github.com/NovaCrypto/BIP39

Все это тщательно протестировано и усовершенствовано на предмет безопасности памяти.