From 1e5e622f8388ef89bb7e9b242859e573a7cab334 Mon Sep 17 00:00:00 2001 From: Pavlos Tzianos Date: Wed, 16 Oct 2019 23:56:28 +0200 Subject: [PATCH] Add module and tests for BIP44 wallets --- .../app/src/main/java/org/hermes/hd/BIP44.kt | 105 ++++++++++++++++++ .../java/org/hermes/hd/BIP44WalletTest.kt | 26 +++++ 2 files changed, 131 insertions(+) create mode 100644 android-client/app/src/main/java/org/hermes/hd/BIP44.kt create mode 100644 android-client/app/src/test/java/org/hermes/hd/BIP44WalletTest.kt diff --git a/android-client/app/src/main/java/org/hermes/hd/BIP44.kt b/android-client/app/src/main/java/org/hermes/hd/BIP44.kt new file mode 100644 index 0000000..b19bc47 --- /dev/null +++ b/android-client/app/src/main/java/org/hermes/hd/BIP44.kt @@ -0,0 +1,105 @@ +package org.hermes.hd + +import org.hermes.utils.toTritArray +import java.lang.Exception + +class BIP44Wallet(seed: ByteArray, keyRegistry: KeyRegistry = InMemoryKeyRegistry()) { + + class InvalidCoinType(): Exception() + + /** + * ExtendedWallet is a class that extends the standard BIP32 compliant wallet so + * that it becomes compliant with BIP44. + * + * Specifically, it injects different keys in some paths according to the specification + * in SLIP44. + * The ExtendedWallet is seeded once and can then be re-used for multiple different + * currencies from the same application. + */ + class ExtendedWallet(seed: ByteArray, private val keyRegistry: KeyRegistry = InMemoryKeyRegistry()): + Wallet(seed, keyRegistry) + { + + companion object { + val BIP44Path = BIP32.normalizeToStr("m/44'") // "m/2147483692" // m/44' + } + + /** + * The get method will intercept all calls and will make sure to inject the correct keys + * wherever necessary. + * + * The implementation is lazy, meaning that the keys will be injected only when requested. + * It does not force the user to use just the BIP44 related paths. + */ + override operator fun get(path: String): BIP32Key { + BIP32.verify(path) + val normalizedPath = BIP32.normalize(path).drop(1) + val normalizedPathStr = BIP32.normalizeToStr(path) + if (normalizedPathStr.startsWith(BIP44Path) and (normalizedPath.size > 1)) { + val coinIndex = normalizedPath[1] + val coinEntry = SLIP44.byWalletIndex[coinIndex] ?: throw InvalidCoinType() + val childPath = BIP32.normalizeToStr("$BIP44Path/${coinEntry.walletIndex}") + // If the special paths have not been set, then put them in place + if ((keyRegistry.get(BIP44Path) == null) or (keyRegistry.get(childPath) == null)) { + val bip44Key = super.get(BIP44Path) + val childKey = if (coinEntry.symbol == "IOTA") { + // Special keys for IOTA + val (chainCode, key) = IOTAExPrivKey.CKDpriv( + coinEntry.walletIndex, + (bip44Key as ExPrivKey).value.toByteArray(), + bip44Key.public.encoded, + bip44Key.chainCode) + IOTAExPrivKey(childPath, bip44Key, chainCode, key.toTritArray().toTryteArray()) + } + else { + // Standard ExPrivKeys + val encoder = when (coinEntry.symbol) { + "ETH" -> ETHKeyEncoder + else -> BTCKeyEncoder + } + val (chainCode, key) = ExPrivKey.CKDPriv( + coinEntry.walletIndex, + (bip44Key as ExPrivKey).value.toByteArray(), + (bip44Key as ExPrivKey).public.encoded, + bip44Key.chainCode) + ExPrivKey(childPath, bip44Key, chainCode, key, encoder) + } + keyRegistry.put(childPath, childKey) + } + } + // One the keys have been put in place, proceed as normal + return super.get(path) + } + } + + class Context(val coinType: String, val account: Int, val change: Int = 0, private val wallet: Wallet) { + + private val coinPath = BIP32.normalizeToStr("m/44'/${SLIP44.bySymbol[coinType]!!.walletIndex}") + private val accountPath = "$account'/$change" + private val internalPath = "$coinPath/$accountPath" + + operator fun get(index: Long): BIP32Key { + if (index >= BIP32.HARDENED_KEY_OFFSET) + throw BIP32Key.InvalidIndex() + return wallet["$internalPath/$index"] + } + + operator fun get(path: String): BIP32Key { + return wallet["$internalPath/$path"] + } + + init { + if (!SLIP44.bySymbol.containsKey(coinType)) + throw InvalidCoinType() + } + } + + // Be careful accessing the wallet this way because you might cause some paths to be initialised. + val masterWallet: Wallet = ExtendedWallet(seed, keyRegistry = keyRegistry) + + fun getWalletForCoin(coinType: String, account: Int, change: Int = 0): Context = + Context(coinType, account, change, masterWallet).apply { + // Use this to initialize the path + get(0L) + } +} \ No newline at end of file diff --git a/android-client/app/src/test/java/org/hermes/hd/BIP44WalletTest.kt b/android-client/app/src/test/java/org/hermes/hd/BIP44WalletTest.kt new file mode 100644 index 0000000..fb5d708 --- /dev/null +++ b/android-client/app/src/test/java/org/hermes/hd/BIP44WalletTest.kt @@ -0,0 +1,26 @@ +package org.hermes.hd + +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Assertions.* + +internal class BIP44WalletTest { + + @Test + fun getWallet() { + val words = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about".split(' ') + val wallet = BIP44Wallet(seed = Mnemonic.fromWordList(words.toTypedArray()).seed) + val btcWallet = wallet.getWalletForCoin("BTC", 0, 0) + val ethWallet = wallet.getWalletForCoin("ETH", 0, 0) + val iotaWallet = wallet.getWalletForCoin("IOTA", 0, 0) + assertTrue(wallet.masterWallet["m"] is ExPrivKey) + assertTrue(wallet.masterWallet["m/44'"] is ExPrivKey) + assertTrue(wallet.masterWallet["m/44'/${SLIP44.bySymbol["BTC"]!!.walletIndex}/0'/0"] is ExPrivKey) + assertTrue(wallet.masterWallet["m/44'/${SLIP44.bySymbol["ETH"]!!.walletIndex}/0'/0"] is ExPrivKey) + assertTrue(wallet.masterWallet["m/44'/${SLIP44.bySymbol["IOTA"]!!.walletIndex}/0'/0"] is IOTAExPrivKey) + assertTrue(iotaWallet[0] is IOTAExPrivKey) + assertArrayEquals( + (iotaWallet[0] as IOTAExPrivKey).value.toByteArray(), + (wallet.masterWallet["m/44'/${SLIP44.bySymbol["IOTA"]!!.walletIndex}/0'/0/0"] as IOTAExPrivKey).value.toByteArray() + ) + } +} \ No newline at end of file