Refactored toEntropy and added missing/failing test scenarios.
This commit is contained in:
parent
3603234999
commit
85bb896549
|
@ -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) }
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue