Refactored toEntropy and added missing/failing test scenarios.

This commit is contained in:
Kevin Gorham 2020-06-06 15:12:47 -04:00
parent 3603234999
commit 85bb896549
No known key found for this signature in database
GPG Key ID: CCA55602DF49FC38
2 changed files with 102 additions and 49 deletions

View File

@ -128,58 +128,73 @@ object Mnemonics {
} }
} }
// verify: checksum (this function contains a checksum validation) // verify: checksum
toEntropy() validateChecksum()
} }
/** /**
* Convert this mnemonic word list to its original entropy value. * Convenience method for validating the checksum of this MnemonicCode. Since validation
* requires deriving the original entropy, this function is the same as calling [toEntropy].
*/
fun validateChecksum() = toEntropy()
/**
* Get the original entropy that was used to create this MnemonicCode. This call will fail
* if the words have an invalid length or checksum.
*
* @throws WordCountException when the word count is zero or not a multiple of 3.
* @throws ChecksumException if the checksum does not match the expected value.
*/ */
fun toEntropy(): ByteArray { fun toEntropy(): ByteArray {
wordCount.let { wordCount -> wordCount.let { if (it <= 0 || it % 3 > 0) throw WordCountException(wordCount) }
if (wordCount % 3 > 0) throw WordCountException(wordCount)
}
if (isEmpty()) throw RuntimeException("Word list is empty.")
// Look up all the words in the list and construct the // Look up all the words in the list and construct the
// concatenation of the original entropy and the checksum. // concatenation of the original entropy and the checksum.
// //
val concatLenBits = wordCount * 11 val totalLengthBits = wordCount * 11
val concatBits = BooleanArray(concatLenBits) val checksumLengthBits = totalLengthBits / 33
var wordindex = 0 val entropy = ByteArray((totalLengthBits - checksumLengthBits) / 8)
val checksumBits = mutableListOf<Boolean>()
// TODO: iterate by characters instead of by words, for a little added security val words = getCachedWords(languageCode)
forEach { word -> var bitsProcessed = 0
// Find the words index in the wordlist. var nextByte = 0.toByte()
val ndx = getCachedWords(languageCode).binarySearch(word) this.forEach {
if (ndx < 0) throw InvalidWordException(word) words.binarySearch(it).let { phraseIndex ->
// fail if the word was not found on the list
// Set the next 11 bits to the value of the index. if (phraseIndex < 0) throw InvalidWordException(it)
for (ii in 0..10) concatBits[wordindex * 11 + ii] = // for each of the 11 bits of the phraseIndex
ndx and (1 shl 10 - ii) != 0 (10 downTo 0).forEach { i ->
++wordindex // isolate the next bit (starting from the big end)
val bit = phraseIndex and (1 shl i) != 0
// if the bit is set, then update the corresponding bit in the nextByte
if (bit) nextByte = nextByte or (1 shl 7 - (bitsProcessed).rem(8)).toByte()
val entropyIndex = ((++bitsProcessed) - 1) / 8
// if we're at a byte boundary (excluding the extra checksum bits)
if (bitsProcessed.rem(8) == 0 && entropyIndex < entropy.size) {
// then set the byte and prepare to process the next byte
entropy[entropyIndex] = nextByte
nextByte = 0.toByte()
// if we're now processing checksum bits, then track them for later
} else if (entropyIndex >= entropy.size) {
checksumBits.add(bit)
}
}
} }
val checksumLengthBits = concatLenBits / 33
val entropyLengthBits = concatLenBits - checksumLengthBits
// Extract original entropy as bytes.
val entropy = ByteArray(entropyLengthBits / 8)
for (ii in entropy.indices)
for (jj in 0..7)
if (concatBits[ii * 8 + jj]) {
entropy[ii] = entropy[ii] or (1 shl 7 - jj).toByte()
} }
// Take the digest of the entropy. // Check each required checksum bit, against the first byte of the sha256 of entropy
val hash: ByteArray = entropy.toSha256() entropy.toSha256()[0].toBits().let { hashFirstByteBits ->
val hashBits = hash.toBits() repeat(checksumLengthBits) { i ->
// failure means that each word was valid BUT they were in the wrong order
if (hashFirstByteBits[i] != checksumBits[i]) throw ChecksumException
}
}
// Check all the checksum bits.
for (i in 0 until checksumLengthBits)
if (concatBits[entropyLengthBits + i] != hashBits[i]) throw ChecksumException
return entropy return entropy
} }
companion object { companion object {
/** /**
@ -317,6 +332,9 @@ fun MnemonicCode.toSeed(
passphrase: CharArray = charArrayOf(), passphrase: CharArray = charArrayOf(),
validate: Boolean = true validate: Boolean = true
): ByteArray { ): ByteArray {
// we can skip validation when we know for sure that the code is valid
// such as when it was just generated from new/correct entropy (common case for new seeds)
if (validate) validate()
return (DEFAULT_PASSPHRASE.toCharArray() + passphrase).toBytes().let { salt -> return (DEFAULT_PASSPHRASE.toCharArray() + passphrase).toBytes().let { salt ->
PBEKeySpec(chars, salt, INTERATION_COUNT, KEY_SIZE).let { pbeKeySpec -> PBEKeySpec(chars, salt, INTERATION_COUNT, KEY_SIZE).let { pbeKeySpec ->
SecretKeyFactory.getInstance(PBE_ALGORITHM).generateSecret(pbeKeySpec).encoded.also { SecretKeyFactory.getInstance(PBE_ALGORITHM).generateSecret(pbeKeySpec).encoded.also {
@ -337,9 +355,9 @@ fun WordCount.toEntropy(): ByteArray = ByteArray(bitLength / 8).apply {
private fun ByteArray?.toSha256() = MessageDigest.getInstance("SHA-256").digest(this) private fun ByteArray?.toSha256() = MessageDigest.getInstance("SHA-256").digest(this)
private fun ByteArray.toBits(): List<Boolean> { private fun ByteArray.toBits(): List<Boolean> = flatMap { it.toBits() }
return flatMap { b -> (7 downTo 0).map { (b.toInt() and (1 shl it)) != 0 } }
} private fun Byte.toBits(): List<Boolean> = (7 downTo 0).map { (toInt() and (1 shl it)) != 0 }
private fun CharArray.toBytes(): ByteArray { private fun CharArray.toBytes(): ByteArray {
val byteBuffer = CharBuffer.wrap(this).let { Charset.forName("UTF-8").encode(it) } val byteBuffer = CharBuffer.wrap(this).let { Charset.forName("UTF-8").encode(it) }

View File

@ -6,10 +6,8 @@ import com.squareup.moshi.JsonClass
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import io.kotest.assertions.asClue import io.kotest.assertions.asClue
import io.kotest.assertions.fail import io.kotest.assertions.throwables.shouldNotThrowAny
import io.kotest.assertions.forEachAsClue
import io.kotest.assertions.throwables.shouldThrow import io.kotest.assertions.throwables.shouldThrow
import io.kotest.assertions.withClue
import io.kotest.core.spec.style.BehaviorSpec import io.kotest.core.spec.style.BehaviorSpec
import io.kotest.data.forAll import io.kotest.data.forAll
import io.kotest.data.row import io.kotest.data.row
@ -139,8 +137,7 @@ class MnemonicsTest : BehaviorSpec({
val mnemonic = it[1].toCharArray() val mnemonic = it[1].toCharArray()
val seed = it[2] val seed = it[2]
val passphrase = "TREZOR".toCharArray() val passphrase = "TREZOR".toCharArray()
val language = Locale.ENGLISH.language MnemonicCode(mnemonic, lang).toSeed(passphrase).toHex() shouldBe seed
MnemonicCode(mnemonic, language).toSeed(passphrase).toHex() shouldBe seed
} }
} }
} }
@ -149,25 +146,54 @@ class MnemonicsTest : BehaviorSpec({
Given("an invalid mnemonic") { Given("an invalid mnemonic") {
When("it was created by swapping two words in a valid mnemonic") { When("it was created by swapping two words in a valid mnemonic") {
// swapped "trend" and "flight" // swapped "trend" and "flight"
val mnemonicPhrase = validPhrase.swap(4, 5) validPhrase.swap(4, 5).asClue { mnemonicPhrase ->
Then("it fails with a checksum error") { Then("validate() fails with a checksum error") {
mnemonicPhrase.asClue {
shouldThrow<Mnemonics.ChecksumException> { shouldThrow<Mnemonics.ChecksumException> {
MnemonicCode(mnemonicPhrase).validate() MnemonicCode(mnemonicPhrase).validate()
} }
} }
Then("toEntropy() fails with a checksum error") {
shouldThrow<Mnemonics.ChecksumException> {
MnemonicCode(mnemonicPhrase).toEntropy()
}
}
Then("toSeed() fails with a checksum error") {
shouldThrow<Mnemonics.ChecksumException> {
MnemonicCode(mnemonicPhrase).toSeed()
}
}
Then("toSeed(validate=false) succeeds!!") {
shouldNotThrowAny {
MnemonicCode(mnemonicPhrase).toSeed(validate = false)
}
}
} }
} }
When("it contains an invalid word") { When("it contains an invalid word") {
val mnemonicPhrase = validPhrase.split(' ').let { words -> val mnemonicPhrase = validPhrase.split(' ').let { words ->
validPhrase.replace(words[23], "convincee") validPhrase.replace(words[23], "convincee")
} }
Then("it fails with a word validation error") {
mnemonicPhrase.asClue { mnemonicPhrase.asClue {
Then("validate() fails with a word validation error") {
shouldThrow<Mnemonics.InvalidWordException> { shouldThrow<Mnemonics.InvalidWordException> {
MnemonicCode(mnemonicPhrase).validate() MnemonicCode(mnemonicPhrase).validate()
} }
} }
Then("toEntropy() fails with a word validation error") {
shouldThrow<Mnemonics.InvalidWordException> {
MnemonicCode(mnemonicPhrase).toEntropy()
}
}
Then("toSeed() fails with a word validation error") {
shouldThrow<Mnemonics.InvalidWordException> {
MnemonicCode(mnemonicPhrase).toSeed()
}
}
Then("toSeed(validate=false) succeeds!!") {
shouldNotThrowAny {
MnemonicCode(mnemonicPhrase).toSeed(validate = false)
}
}
} }
} }
When("it contains an unsupported number of words") { When("it contains an unsupported number of words") {
@ -176,6 +202,15 @@ class MnemonicsTest : BehaviorSpec({
shouldThrow<Mnemonics.WordCountException> { shouldThrow<Mnemonics.WordCountException> {
MnemonicCode(mnemonicPhrase).validate() MnemonicCode(mnemonicPhrase).validate()
} }
shouldThrow<Mnemonics.WordCountException> {
MnemonicCode(mnemonicPhrase).toEntropy()
}
shouldThrow<Mnemonics.WordCountException> {
MnemonicCode(mnemonicPhrase).toSeed()
}
shouldNotThrowAny {
MnemonicCode(mnemonicPhrase).toSeed(validate = false)
}
} }
} }
} }