Merge branch 'feat/wallet-keys-and-address' into 'main'
Derive wallet addresses and sign with them (Android) See merge request paulhitt/flamenet-messenger-android!45
This commit was merged in pull request #72.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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, HdAccount> =
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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