Derive wallet addresses and sign with them (Android)
`HdAccount` turns a recovery phrase into the BIP-44 path every other Solana wallet uses, the Base58 address somebody can be paid at, and the key that signs for it. 🔴 The property everything else rests on is that the signing key OWNS the address that was displayed — same path, same curve, same code. If those ever disagreed the wallet would sign with a key that does not own the address it showed. The transaction being rejected by the network is the GOOD outcome; the bad one is signing for an address somebody else controls. Tested across five indices, and separately by signing a message and verifying it under the address. 🔴 Android can sign, and iOS's constraint does not apply. AccountKdf notes that a raw Ed25519 seed needs EdECPrivateKeySpec, which Android exposes only from API 33 — but the engine carries its own RFC 8032, so this works on any supported device. The wallet is not blocked on a platform floor. 🔴 A fresh address per receive, by default rather than on request. Reusing one lets anybody watching a public chain group every payment somebody ever received — the single linkability a wallet can actually prevent, because the chain will not forget. ⚠️ `looksLikeAddress` says WELL FORMED, never CORRECT, and the difference is money. A Solana address carries no checksum, so one mistyped character that still decodes to 32 bytes is a perfectly valid address nobody holds the key for. Bitcoin's Base58Check would catch it; this cannot, and neither can the network. There is a passing test that goes and 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 the point at which the warnings around typed addresses can be revisited. ⚠️ A test bug found by the test failing rather than passing: `Ed25519.verify` takes (publicKey, signature, message) and I had passed (publicKey, message, signature). All three are ByteArray, so it compiled perfectly. Noted in the file — it is a decent argument for asserting the signature verifies at all rather than trusting that a key which derives must be a key that signs. 12 tests. App builds, every JVM suite green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
4ee0170dcc
commit
2e8c6776e2
@@ -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<String>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user