diff --git a/PARITY.md b/PARITY.md index 684db2d..b3a62d9 100644 --- a/PARITY.md +++ b/PARITY.md @@ -339,7 +339,8 @@ the messenger rather than staying separate apps. | Local vault: add, organise, delete | βšͺ | | Bell–LaPadula labels and clearance | βšͺ | | Share a file into a chat or group | βšͺ | -| Wallet engine: BIP-39, Base58, SLIP-0010 | 🟑 primitives built and pinned to the published vectors; no screen yet | +| Wallet engine: BIP-39, Base58, SLIP-0010 | βœ… pinned to the published vectors | +| Wallet engine: address derivation and signing | βœ… BIP-44 path, Base58 address, and the engine's own RFC 8032 signs β€” no API-33 floor | | Wallet: BIP-39 seed, create and restore | βšͺ | | Wallet: fresh receive address per receive | βšͺ | | Wallet: balance and transactions from a node | βšͺ | @@ -372,6 +373,20 @@ Two Kotlin-specific decisions, both money-relevant: `BigInteger(1, bytes).toString(58)` remapped, and it silently drops leading zero bytes β€” encoding two different keys to one address. +πŸ”΄ **Android can SIGN, and iOS's constraint does not apply here.** `AccountKdf` +notes that a raw Ed25519 seed needs `EdECPrivateKeySpec`, which Android exposes +only from API 33 β€” but the engine carries its own RFC 8032 (`Rfc8032`), so +`HdAccount.signingSeed` produces a key that signs on any supported device. The +wallet is not blocked on a platform floor. + +⚠️ **A Solana address has no checksum, and `looksLikeAddress` says "well +formed", never "correct".** A single mistyped character that still decodes to 32 +bytes is a perfectly valid address nobody holds the key for; money sent there is +gone, and neither the client nor the network will catch it. There is a passing +test that *finds* such a near-miss, so the limit is recorded rather than implied +β€” and it fails if the format ever gains a checksum, which is when the warnings +around typed addresses can be revisited. + --- ## Settings diff --git a/engine/src/main/kotlin/io/flamenet/e2e/HdAccount.kt b/engine/src/main/kotlin/io/flamenet/e2e/HdAccount.kt new file mode 100644 index 0000000..751a091 --- /dev/null +++ b/engine/src/main/kotlin/io/flamenet/e2e/HdAccount.kt @@ -0,0 +1,95 @@ +package io.flamenet.e2e + +/** + * An account's receive keys, and the address each one prints as. + * + * πŸ”΄ **[next] moves the index on.** A wallet that handed out the same address + * every time would let anyone watching a public chain group every payment + * somebody ever received β€” which is the one linkability the wallet can actually + * prevent, so it prevents it by default rather than on request. The chain + * cannot be persuaded to forget; this is the only lever there is. + * + * ⚠️ Every level is hardened, because SLIP-0010 defines no non-hardened + * derivation for ed25519. See [HdKey]. + */ +data class HdAccount( + val seed: ByteArray, + /** SLIP-44 coin type. 501 is Solana's; ed25519 chains each have their own. */ + val coinType: Long = SOLANA, + val account: Long = 0, + val nextIndex: Long = 0, +) { + override fun equals(other: Any?) = other is HdAccount && + seed.contentEquals(other.seed) && coinType == other.coinType && + account == other.account && nextIndex == other.nextIndex + + override fun hashCode() = + 31 * seed.contentHashCode() + (coinType * 31 + account * 7 + nextIndex).toInt() + + /** BIP-44 shape, every level hardened as this curve requires. */ + fun path(index: Long): String = "m/44H/${coinType}H/${account}H/0H/${index}H" + + fun key(index: Long): HdKey = HdKey.master(seed, path(index)) + + /** + * The address at [index], and the path it came from. + * + * πŸ”΄ **Base58 of the raw public key, with no checksum, because Solana + * addresses have none.** A mistyped address that still decodes to 32 bytes + * is a *valid* address belonging to nobody β€” money sent there is gone, and + * there is no layer below this that will catch it. Anything offering an + * address to be typed has to say so; this function cannot. + */ + fun receive(index: Long): Receive = + Receive( + index = index, + address = Base58.encode(Ed25519.fromSeed(key(index).privateKey).publicKey), + path = path(index), + ) + + data class Receive(val index: Long, val address: String, val path: String) + + /** + * The signing key for [index]. + * + * πŸ”΄ **Same path, same curve, same code as [receive] β€” deliberately.** If + * these two ever disagreed the wallet would sign with a key that does not + * own the address it displayed. The transaction being rejected by the + * network is the GOOD outcome; the bad one is signing for an address + * somebody else controls. + */ + fun signingSeed(index: Long): ByteArray = key(index).privateKey + + /** The next unused receive key, and the account with its counter advanced. */ + fun next(): Pair = + receive(nextIndex) to copy(nextIndex = nextIndex + 1) + + companion object { + /** + * ⚠️ Solana's SLIP-44 coin type: the ed25519 chain family this + * derivation is defined for. Other ed25519 chains differ only in this + * number. secp256k1 chains (Bitcoin, Ethereum) need curve arithmetic + * this engine does not carry. + */ + const val SOLANA: Long = 501 + + /** + * Whether [address] could be an address on this chain. + * + * πŸ”΄ **This says "well formed", NEVER "correct", and the difference is + * money.** A Solana address is Base58 of a 32-byte public key and + * carries no checksum β€” so a single mistyped character that happens to + * decode to 32 bytes produces a perfectly valid address that nobody + * holds the key for. Bitcoin's Base58Check would catch that; this + * cannot, and neither can the network. + * + * The only real defences are pasting rather than typing, and showing + * the address back in a form somebody can compare. A caller that treats + * a `true` here as "this address is right" has misread it. + */ + fun looksLikeAddress(address: String): Boolean { + val decoded = Base58.decode(address) ?: return false + return decoded.size == 32 + } + } +} diff --git a/engine/src/test/kotlin/io/flamenet/e2e/HdAccountTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/HdAccountTest.kt new file mode 100644 index 0000000..2106bf4 --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/HdAccountTest.kt @@ -0,0 +1,190 @@ +package io.flamenet.e2e + +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +/** + * From a recovery phrase to an address somebody can be paid at. + * + * πŸ”΄ **The property that matters most here is that the signing key and the + * displayed address come from the same derivation.** If they ever disagreed, + * the wallet would sign with a key that does not own the address it showed. The + * transaction being rejected is the GOOD outcome; the bad one is signing for an + * address somebody else controls. + */ +class HdAccountTest { + + private val words by lazy { Bip39.englishWordlist() } + private val phrase by lazy { Bip39.phrase(ByteArray(16), words) } + private val seed by lazy { Bip39.seed(phrase) } + + private fun account() = HdAccount(seed) + + // ---- The path -------------------------------------------------------- + + /** + * ⚠️ BIP-44 with every level hardened, which is what this curve requires + * and what every other Solana wallet uses. A different shape here derives + * addresses nobody else can reproduce from the same phrase. + */ + @Test + fun `the derivation path is the standard Solana shape`() { + assertEquals("m/44H/501H/0H/0H/0H", account().path(0)) + assertEquals("m/44H/501H/0H/0H/7H", account().path(7)) + assertEquals("m/44H/501H/3H/0H/0H", HdAccount(seed, account = 3).path(0)) + } + + // ---- The property that guards the money ------------------------------ + + /** + * πŸ”΄ The signing key owns the address that was displayed. Everything else + * in the wallet rests on this one line being true. + */ + @Test + fun `the signing key owns the address that was shown`() { + val account = account() + for (index in 0L..4L) { + val shown = account.receive(index) + val signing = account.signingSeed(index) + val fromSigningKey = Base58.encode(Ed25519.fromSeed(signing).publicKey) + assertEquals(shown.address, fromSigningKey, + "index $index would sign with a key that does not own its address") + } + } + + /** And that key can actually sign β€” the engine carries RFC 8032 itself. */ + @Test + fun `a derived key can sign and the signature verifies under its address`() { + val account = account() + val shown = account.receive(0) + val seedAt0 = account.signingSeed(0) + val message = "a transaction".toByteArray() + val signature = Ed25519.sign(seedAt0, message) + val publicKey = Base58.decode(shown.address)!! + // ⚠️ `(publicKey, signature, message)`. All three are ByteArray, so + // getting the order wrong compiles perfectly and fails at runtime β€” + // which is what happened writing this test, and is a good argument for + // the assertion existing at all. + assertTrue(Ed25519.verify(publicKey, signature, message), + "the signature does not verify under the address it was made for") + } + + // ---- Fresh addresses ------------------------------------------------- + + /** + * πŸ”΄ **A new address per receive is the point.** Reusing one lets anybody + * watching a public chain group every payment somebody ever received β€” the + * one linkability a wallet can prevent, since the chain will not forget. + */ + @Test + fun `every index gives a different address`() { + val account = account() + val addresses = (0L..9L).map { account.receive(it).address } + assertEquals(10, addresses.toSet().size, "two indices produced one address") + } + + @Test + fun `next advances the counter and never repeats`() { + var account = account() + val seen = mutableListOf() + repeat(5) { + val (receive, advanced) = account.next() + seen += receive.address + account = advanced + } + assertEquals(5, seen.toSet().size) + assertEquals(5L, account.nextIndex) + } + + /** Different accounts under one phrase are different wallets. */ + @Test + fun `account numbers separate`() { + assertNotEquals( + HdAccount(seed, account = 0).receive(0).address, + HdAccount(seed, account = 1).receive(0).address, + ) + } + + // ---- Restoring ------------------------------------------------------- + + /** + * ⚠️ The whole promise of a recovery phrase: the same words give back the + * same addresses, on any device and any implementation. + */ + @Test + fun `the same phrase restores the same addresses`() { + val restored = HdAccount(Bip39.seed(Bip39.phrase(ByteArray(16), words))) + assertEquals(account().receive(0).address, restored.receive(0).address) + assertEquals(account().receive(3).address, restored.receive(3).address) + } + + /** A different phrase is a different wallet, not a near-miss. */ + @Test + fun `a different phrase gives entirely different addresses`() { + val other = HdAccount(Bip39.seed(Bip39.phrase(ByteArray(16) { 1 }, words))) + assertNotEquals(account().receive(0).address, other.receive(0).address) + } + + // ---- What an address is, and is not ---------------------------------- + + /** An address is Base58 of 32 bytes, so it decodes back to the key. */ + @Test + fun `an address decodes back to a 32-byte key`() { + val shown = account().receive(0) + val decoded = Base58.decode(shown.address) + assertContentEquals( + Ed25519.fromSeed(account().signingSeed(0)).publicKey, + decoded, + ) + assertEquals(32, decoded!!.size) + } + + @Test + fun `a well-formed address is recognised`() { + assertTrue(HdAccount.looksLikeAddress(account().receive(0).address)) + } + + @Test + fun `something that is not an address is rejected`() { + assertFalse(HdAccount.looksLikeAddress("")) + assertFalse(HdAccount.looksLikeAddress("not an address")) + // Right alphabet, wrong length. + assertFalse(HdAccount.looksLikeAddress("abc")) + // Characters the alphabet excludes so a hand-copied address cannot + // become a different valid one. + assertFalse(HdAccount.looksLikeAddress("0OIl0OIl0OIl0OIl0OIl0OIl0OIl0OIl")) + } + + /** + * πŸ”΄ **The limit, asserted as a passing test so nobody reads + * `looksLikeAddress` as stronger than it is.** + * + * A Solana address carries NO checksum. A mistyped character that still + * decodes to 32 bytes is a perfectly valid address that nobody holds the + * key for β€” money sent there is gone, and neither this function nor the + * network will catch it. Bitcoin's Base58Check would; this cannot. + * + * The only real defences are pasting rather than typing, and showing the + * address back to be compared. + */ + @Test + fun `a corrupted address can still look valid, and that is not fixable here`() { + val real = account().receive(0).address + // Walk the alphabet at one position until the result still decodes to + // 32 bytes. On a checksummed format this search would fail. + val nearMiss = Base58.ALPHABET.asSequence() + .map { real.take(3) + it + real.drop(4) } + .firstOrNull { it != real && HdAccount.looksLikeAddress(it) } + assertTrue( + nearMiss != null, + "no single-character change produced a still-valid address β€” if this " + + "ever becomes true the format has gained a checksum, and the " + + "warnings written around typed addresses can be revisited", + ) + assertNotEquals(real, nearMiss) + } +}