package io.flamenet.e2e import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertNotEquals import kotlin.test.assertNull import kotlin.test.assertTrue /** * BIP-39, Base58 and SLIP-0010 against the published vectors. * * 🔴 **These are pinned to the STANDARDS, not to the Swift engine.** Testing * one implementation against the other proves only that they agree, and two * implementations agreeing on the wrong answer is exactly how a wallet ends up * empty: a phrase written down on an iPhone has to restore on an Android * handset, and on somebody else's software years from now. So the vectors here * are BIP-39's own, SLIP-0010's own, and the published word-list digest — which * the Swift engine is also held to. * * ⚠️ **Every failure in this file is silent in production.** A wrong checksum * bit, a lost leading zero, a missed NFKD: none of them error. They derive a * different, perfectly valid, empty wallet, and the way somebody finds out is * restoring their phrase and seeing nothing there. */ class WalletPrimitivesTest { private fun hex(bytes: ByteArray) = bytes.joinToString("") { "%02x".format(it) } private fun unhex(text: String) = ByteArray(text.length / 2) { text.substring(it * 2, it * 2 + 2).toInt(16).toByte() } private val words by lazy { Bip39.englishWordlist() } // ---- The word list --------------------------------------------------- /** * 🔴 The whole mitigation for vendoring 2,048 strings. A silently corrupted * list does not error — it produces a DIFFERENT wallet from the same * phrase. */ @Test fun `the vendored word list matches its published fingerprint`() { assertEquals(2048, words.size) Bip39.verify(words) assertEquals("abandon", words.first()) assertEquals("zoo", words.last()) } @Test fun `a corrupted word list is refused`() { val tampered = words.toMutableList().also { it[7] = "banana" } assertFailsWith { Bip39.verify(tampered) } assertFailsWith { Bip39.verify(words.drop(1)) } } // ---- BIP-39 vectors -------------------------------------------------- /** * The canonical first vector from BIP-39's own test file: all-zero entropy. */ @Test fun `all-zero entropy produces the canonical phrase and seed`() { val entropy = ByteArray(16) val phrase = Bip39.phrase(entropy, words) assertEquals( "abandon abandon abandon abandon abandon abandon abandon abandon " + "abandon abandon abandon about", phrase, ) assertContentEquals(entropy, Bip39.entropy(phrase, words)) assertEquals( "c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e5349553" + "1f09a6987599d18264c1e1c92f2cf141630c7a3c4ab7c81b2f001698e7463b04", hex(Bip39.seed(phrase, passphrase = "TREZOR")), ) } /** A 24-word vector, to exercise the longer checksum. */ @Test fun `the all-ones 32-byte vector round-trips`() { val entropy = ByteArray(32) { 0xff.toByte() } val phrase = Bip39.phrase(entropy, words) assertEquals(24, phrase.split(" ").size) assertEquals("zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo " + "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo vote", phrase) assertContentEquals(entropy, Bip39.entropy(phrase, words)) // ⚠️ This expectation was WRONG in the first version of this file — it // carried the seed from a different BIP-39 vector, and the Kotlin // answer below is the correct one. It was resolved by computing the // value independently (PBKDF2-HMAC-SHA512, NFKD, 2048 rounds) rather // than by adopting whatever the implementation produced, which is the // only way a vector test is worth anything: a pinned value taken from // the code under test pins the bug. assertEquals( "dd48c104698c30cfe2b6142103248622fb7bb0ff692eebb00089b32d22484e16" + "13912f0a5b694407be899ffd31ed3992c456cdf60f5d4564b8ba3f05a69890ad", hex(Bip39.seed(phrase, passphrase = "TREZOR")), ) } /** A mid-length vector, so 18 words is not left to inference. */ @Test fun `an eighteen-word vector round-trips`() { val entropy = unhex("808080808080808080808080808080808080808080808080") val phrase = Bip39.phrase(entropy, words) assertEquals( "letter advice cage absurd amount doctor acoustic avoid letter " + "advice cage absurd amount doctor acoustic avoid letter always", phrase, ) assertContentEquals(entropy, Bip39.entropy(phrase, words)) } /** * 🔴 **The passphrase silently names a different wallet.** That is BIP-39's * design, and it is the sharpest edge in the whole standard — somebody who * mistypes it does not get an error, they get an empty account. */ @Test fun `a different BIP-39 passphrase derives a different seed`() { val phrase = Bip39.phrase(ByteArray(16), words) assertNotEquals(hex(Bip39.seed(phrase)), hex(Bip39.seed(phrase, "TREZOR"))) assertNotEquals(hex(Bip39.seed(phrase, "a")), hex(Bip39.seed(phrase, "A"))) } // ---- BIP-39 refusals ------------------------------------------------- @Test fun `a mistyped word is refused rather than resolved to a neighbour`() { val phrase = Bip39.phrase(ByteArray(16), words).replace("about", "abut") assertFailsWith { Bip39.entropy(phrase, words) } } /** * 🔴 The checksum's whole job: a phrase of real words in a plausible order * that is not a phrase anybody generated. */ @Test fun `a phrase that fails its checksum is refused`() { val phrase = Bip39.phrase(ByteArray(16), words).replace("about", "abandon") assertFailsWith { Bip39.entropy(phrase, words) } } @Test fun `a phrase of the wrong length is refused`() { val short = Bip39.phrase(ByteArray(16), words).split(" ").drop(1).joinToString(" ") assertFailsWith { Bip39.entropy(short, words) } } @Test fun `entropy of a size the standard does not define is refused`() { assertFailsWith { Bip39.phrase(ByteArray(15), words) } assertFailsWith { Bip39.phrase(ByteArray(64), words) } } /** ⚠️ Extra spacing is a typing artefact, not a different phrase. */ @Test fun `surrounding and repeated whitespace does not change the phrase`() { val phrase = Bip39.phrase(ByteArray(16), words) assertContentEquals( Bip39.entropy(phrase, words), Bip39.entropy(" " + phrase.replace(" ", " ") + "\n", words), ) } // ---- Base58 ---------------------------------------------------------- /** * 🔴 **The leading-zero rule, which is where money goes missing.** Base58 is * base conversion of one integer, and an integer cannot hold a leading zero * byte — the standard prepends one `1` per zero byte instead. An * implementation using `BigInteger` loses them and encodes two different * keys to one address. */ @Test fun `leading zero bytes each become a one`() { assertEquals("1", Base58.encode(byteArrayOf(0))) assertEquals("11", Base58.encode(byteArrayOf(0, 0))) assertEquals("1111", Base58.encode(byteArrayOf(0, 0, 0, 0))) // And a zero byte in front of real data is not the same as without it. assertNotEquals( Base58.encode(byteArrayOf(1, 2, 3)), Base58.encode(byteArrayOf(0, 1, 2, 3)), ) } @Test fun `base58 round-trips, zeros included`() { for (case in listOf( byteArrayOf(), byteArrayOf(0), byteArrayOf(0, 0, 0), byteArrayOf(1), byteArrayOf(0, 1, 2, 3, 4, 5), ByteArray(32) { it.toByte() }, ByteArray(32) { 0xff.toByte() }, ByteArray(32), )) { assertContentEquals(case, Base58.decode(Base58.encode(case)), "round trip lost ${hex(case)}") } } @Test fun `the published Bitcoin vectors encode as stated`() { assertEquals("2NEpo7TZRRrLZSi2U", Base58.encode("Hello World!".toByteArray())) assertEquals( "USm3fpXnKG5EUBx2ndxBDMPVciP5hGey2Jh4NDv6gmeo1LkMeiKrLJUUBk6Z", Base58.encode("The quick brown fox jumps over the lazy dog.".toByteArray()), ) } /** * ⚠️ A mistyped address must FAIL, not resolve to somebody else's. `0`, * `O`, `I` and `l` are absent from the alphabet for exactly this reason. */ @Test fun `a character outside the alphabet decodes to nothing`() { assertNull(Base58.decode("0OIl")) assertNull(Base58.decode("abc def")) assertNull(Base58.decode("abc+def")) } // ---- SLIP-0010 vectors ----------------------------------------------- /** * SLIP-0010's own ed25519 test vector one. The chain code is checked as * well as the key: a derivation that gets the key right and the chain code * wrong produces correct CHILDREN of nothing, and diverges one level down. */ @Test fun `slip10 ed25519 vector one`() { val seed = unhex("000102030405060708090a0b0c0d0e0f") val master = HdKey.master(seed) assertEquals( "2b4be7f19ee27bbf30c667b642d5f4aa69fd169872f8fc3059c08ebae2eb19e7", hex(master.privateKey), ) assertEquals( "90046a93de5380a72b5e45010748567d5ea02bbf6522f979e05c0d8d8ca9fffb", hex(master.chainCode), ) val child = master.derive("m/0H") assertEquals( "68e0fe46dfb67e368c75379acec591dad19df3cde26e63b93a8e704f1dade7a3", hex(child.privateKey), ) assertEquals( "8b59aa11380b624e81507a27fedda59fea6d0b779a778918a2fd3590e16e9c69", hex(child.chainCode), ) val deeper = master.derive("m/0H/1H/2H/2H/1000000000H") assertEquals( "8f94d394a8e8fd6b1bc2f3f49f5c47e385281d5c17e65324b0f62483e37e8793", hex(deeper.privateKey), ) } /** SLIP-0010's ed25519 vector two, with a longer seed. */ @Test fun `slip10 ed25519 vector two`() { val seed = unhex( "fffcf9f6f3f0edeae7e4e1dedbd8d5d2cfccc9c6c3c0bdbab7b4b1aeaba8a5a2" + "9f9c999693908d8a8784817e7b7875726f6c696663605d5a5754514e4b484542" ) val master = HdKey.master(seed) assertEquals( "171cb88b1b3c1db25add599712e36245d75bc65a1a5c9e18d76f9f2b1eab4012", hex(master.privateKey), ) val child = master.derive("m/0H/2147483647H/1H/2147483646H/2H") assertEquals( "551d333177df541ad876a60ea71f00447931c0a9da16f227c11ea080d7391b8d", hex(child.privateKey), ) } /** * 🔴 **A path without hardening is REFUSED, not silently hardened.** * SLIP-0010 defines no non-hardened derivation for ed25519. Somebody * writing `m/44` means non-hardened; quietly handing them a different key * than every other wallet would give them an address nobody else can * reproduce from the same phrase. */ @Test fun `an unhardened path component is refused`() { val master = HdKey.master(ByteArray(32) { 1 }) assertFailsWith { master.derive("m/44") } assertFailsWith { master.derive("m/44H/501") } } /** ⚠️ All three hardening notations are accepted, since all three are written. */ @Test fun `H and h and apostrophe all mean hardened`() { val master = HdKey.master(ByteArray(32) { 1 }) assertEquals(master.derive("m/44H"), master.derive("m/44h")) assertEquals(master.derive("m/44H"), master.derive("m/44'")) } @Test fun `a malformed path is refused`() { val master = HdKey.master(ByteArray(32) { 1 }) assertFailsWith { master.derive("m/notanumberH") } assertFailsWith { master.derive("m/99999999999H") } } @Test fun `a seed outside the standard's range is refused`() { assertFailsWith { HdKey.master(ByteArray(15)) } assertFailsWith { HdKey.master(ByteArray(65)) } } /** * ⚠️ Derivation is a pure function of seed and path. Sounds obvious; it is * the property a restored wallet depends on entirely. */ @Test fun `the same phrase and path always give the same key`() { val seed = Bip39.seed(Bip39.phrase(ByteArray(16), words)) assertEquals( HdKey.master(seed, "m/44H/501H/0H/0H"), HdKey.master(seed, "m/44H/501H/0H/0H"), ) assertNotEquals( HdKey.master(seed, "m/44H/501H/0H/0H"), HdKey.master(seed, "m/44H/501H/1H/0H"), ) } /** * The BIP-44 shape the wallet actually uses, end to end from a phrase. * Not a published vector — the point is that the whole chain runs and that * successive accounts differ, which is what "a fresh address per receive" * rests on. */ @Test fun `a phrase walks the wallet path and gives distinct keys per account`() { val seed = Bip39.seed(Bip39.phrase(ByteArray(32), words)) val keys = (0..4).map { HdKey.master(seed, "m/44H/501H/${it}H/0H").privateKey } assertEquals(5, keys.map { hex(it) }.toSet().size, "two accounts derived the same key") assertTrue(keys.all { it.size == 32 }) } }