diff --git a/PARITY.md b/PARITY.md index b3a62d9..163b954 100644 --- a/PARITY.md +++ b/PARITY.md @@ -341,7 +341,7 @@ the messenger rather than staying separate apps. | Share a file into a chat or group | ⚪ | | 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: BIP-39 seed, create and restore | 🟡 engine done — create, import, restore, sealed at rest; no screen yet | | Wallet: fresh receive address per receive | ⚪ | | Wallet: balance and transactions from a node | ⚪ | @@ -379,6 +379,25 @@ 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 wallet is not derivable from the account, and that is asserted rather +than intended.** The entropy is fresh randomness — never the passphrase, the +handle, or the Flamenet identity. Deriving it from the passphrase would be +convenient, would need no backup at all, and would tie every payment somebody +ever received to their handle on a public ledger for ever. The chain does not +forget, so there is no undoing it later. + +⚠️ **The record stores the ENTROPY, never the seed.** The seed is a one-way +function of the phrase, so a record holding it would open the wallet and be +unable to produce the words — somebody who lost their phrase but still had their +account could spend, and could never write it down again or move elsewhere. +Self-custody in name only. + +⚠️ **Erasing the device destroys the record; the phrase is then the only copy.** +Plain sign-out keeps it, and so does a password change (which signs out +plainly) — `signOut(erasingDevice = true)` and `deleteAccount` do not. That is +what a recovery phrase is for, and it has to be said at the moment the phrase is +shown rather than discovered afterwards. + ⚠️ **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 diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt index 2afd894..78150e4 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt @@ -1667,6 +1667,59 @@ class Vault(private val storage: Storage, private val key: SecretKey) { storage.delete(pinnedName(threadId)) } + // -- Wallet ------------------------------------------------------------- + + private val walletName = "wallet-record" + + /** + * The wallet's ENTROPY, sealed at rest, or null if there is no wallet. + * + * 🔴 **The entropy, never the seed.** The seed is a one-way function of the + * phrase, so a record holding it would open the wallet and be unable to + * produce the words — somebody who lost their phrase but still had their + * account could spend, and could never write the phrase down again or move + * to another wallet. Self-custody in name only. Keeping the entropy means + * the phrase can always be regenerated from the record. + * + * ⚠️ **Sealed by the vault, which means erasing this device destroys it.** + * Plain sign-out keeps it (and so does a password change, which signs out + * plainly); "erase this phone" and "delete account" do not. After those, the + * recovery phrase is the only copy that exists — which is the whole point of + * a recovery phrase, and has to be SAID rather than discovered. + */ + fun walletEntropy(): ByteArray? = + readJson(walletName)?.optString("entropy")?.takeIf { it.isNotEmpty() } + ?.let { runCatching { Bytes.unb64(it) }.getOrNull() } + + fun saveWalletEntropy(entropy: ByteArray) { + val existing = readJson(walletName) + writeJson(walletName, JSONObject() + .put("entropy", Bytes.b64(entropy)) + // ⚠️ The counter is carried across a rewrite rather than reset. A + // restore that zeroed it would hand out addresses somebody has + // already been paid at, undoing the one linkability the wallet can + // prevent. + .put("next", existing?.optInt("next", 0) ?: 0)) + } + + /** How many receive addresses have been handed out. */ + fun walletNextIndex(): Int = readJson(walletName)?.optInt("next", 0) ?: 0 + + /** + * Advance the receive counter. + * + * ⚠️ Never goes backwards, for the reason above: an index that repeated + * would show an address that has already been used. + */ + fun saveWalletNextIndex(next: Int) { + val record = readJson(walletName) ?: return + if (next <= record.optInt("next", 0)) return + writeJson(walletName, record.put("next", next)) + } + + /** Forget the wallet entirely. The phrase is then the only copy. */ + fun forgetWallet() = storage.delete(walletName) + // -- Home-record rollback floor (§20.4) ---------------------------------- private fun homeSerialName(fnid: String) = "home-serial-${Bytes.b64(Bytes.sha256(fnid.toByteArray(Charsets.UTF_8))).take(22).replace('/', '_')}" diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Wallet.kt b/engine/src/main/kotlin/io/flamenet/e2e/Wallet.kt new file mode 100644 index 0000000..5f81844 --- /dev/null +++ b/engine/src/main/kotlin/io/flamenet/e2e/Wallet.kt @@ -0,0 +1,75 @@ +package io.flamenet.e2e + +import java.security.SecureRandom + +/** + * Creating, importing and reading back a wallet. + * + * 🔴 **Addresses are independent of who you are, by construction.** The entropy + * is fresh randomness — never derived from the passphrase, the handle, or the + * Flamenet identity — so no address can be computed from an account and no + * account from an address. Deriving a wallet from the account passphrase would + * be *convenient*, would need no backup at all, and would tie every payment + * somebody ever received to their handle on a public ledger for ever. It is the + * one shortcut this file exists to refuse. + * + * ⚠️ **The phrase is the only copy that survives erasing the device.** The + * record is sealed by the vault, so plain sign-out keeps it and a password + * change keeps it — but "erase this phone" and "delete account" destroy it. + * That is what a recovery phrase is for, and it has to be said out loud at the + * moment the phrase is shown, not discovered afterwards. + */ +object Wallet { + + /** + * A new wallet: the phrase to write down, and the entropy to store. + * + * ⚠️ 256 bits by default — 24 words. The extra length costs nothing to + * store and is what people expect from a wallet; 128 bits stays available + * for anybody who wants 12. + * + * 🔴 `SecureRandom()` with no seeding call. Seeding it from anything — + * a timestamp, a device id, the account — would make the wallet + * reconstructible from things that are not secret, which is the same + * failure as deriving it from the passphrase. + */ + fun create(words: List, entropyBytes: Int = 32, random: SecureRandom = SecureRandom()): + Created { + val entropy = ByteArray(entropyBytes).also(random::nextBytes) + return Created(phrase = Bip39.phrase(entropy, words), entropy = entropy) + } + + data class Created(val phrase: String, val entropy: ByteArray) { + override fun equals(other: Any?) = other is Created && + phrase == other.phrase && entropy.contentEquals(other.entropy) + override fun hashCode() = 31 * phrase.hashCode() + entropy.contentHashCode() + } + + /** + * Adopt a phrase that came from somewhere else. + * + * ⚠️ **Validated before it is stored.** A phrase that fails its checksum is + * refused here rather than saved and discovered later, when the wallet it + * names turns out to be empty and nobody can say why. + */ + fun import(phrase: String, words: List): ByteArray = + Bip39.entropy(phrase, words) + + /** The phrase again, from stored entropy — for showing it a second time. */ + fun phrase(entropy: ByteArray, words: List): String = + Bip39.phrase(entropy, words) + + /** + * The 64-byte seed everything derives from. + * + * ⚠️ [passphrase] is BIP-39's, not the account's. A different one silently + * yields a DIFFERENT valid wallet — the standard's design, and a sharp edge + * worth naming wherever it is offered to somebody. + */ + fun seed(entropy: ByteArray, words: List, passphrase: String = ""): ByteArray = + Bip39.seed(phrase(entropy, words), passphrase) + + /** The account this wallet's addresses come from. */ + fun account(entropy: ByteArray, words: List, nextIndex: Long = 0): HdAccount = + HdAccount(seed(entropy, words), nextIndex = nextIndex) +} diff --git a/engine/src/test/kotlin/io/flamenet/e2e/WalletTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/WalletTest.kt new file mode 100644 index 0000000..3fffaef --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/WalletTest.kt @@ -0,0 +1,147 @@ +package io.flamenet.e2e + +import java.security.SecureRandom +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +/** + * Creating, importing and reading back a wallet. + * + * 🔴 **The property worth guarding hardest is a negative one: the wallet must + * NOT be derivable from the account.** Deriving it from the passphrase would be + * convenient, would need no backup at all, and would tie every payment somebody + * ever received to their handle on a public ledger for ever. There is no way to + * undo that later — the chain does not forget — so it is asserted rather than + * left to good intentions. + */ +class WalletTest { + + private val words by lazy { Bip39.englishWordlist() } + + /** Deterministic randomness, so a create() can be asserted against. */ + private fun fixedRandom(fill: Byte) = object : SecureRandom() { + override fun nextBytes(bytes: ByteArray) { bytes.fill(fill) } + } + + // ---- What a wallet is not -------------------------------------------- + + /** + * 🔴 Two wallets made in a row share nothing. If `create` ever started + * deriving from something about the account, this is what would catch it. + */ + @Test + fun `every wallet created is a different one`() { + val phrases = (1..8).map { Wallet.create(words).phrase } + assertEquals(8, phrases.toSet().size, "create() is not producing fresh entropy") + } + + @Test + fun `a created wallet is 24 words by default and can be 12`() { + assertEquals(24, Wallet.create(words).phrase.split(" ").size) + assertEquals(12, Wallet.create(words, entropyBytes = 16).phrase.split(" ").size) + } + + /** The entropy really is what the phrase encodes, not a parallel value. */ + @Test + fun `the phrase and the stored entropy are the same secret`() { + val made = Wallet.create(words, random = fixedRandom(0x2a)) + assertContentEquals(made.entropy, Bip39.entropy(made.phrase, words)) + assertEquals(made.phrase, Wallet.phrase(made.entropy, words)) + } + + // ---- Importing ------------------------------------------------------- + + /** + * ⚠️ Validated before it is stored. A phrase that fails its checksum is + * refused here rather than saved and discovered later, when the wallet it + * names turns out to be empty and nobody can say why. + */ + @Test + fun `a phrase that does not check out is refused at import`() { + val real = Wallet.create(words).phrase + val broken = real.split(" ").toMutableList().also { it[0] = it[1] }.joinToString(" ") + assertFailsWith { Wallet.import(broken, words) } + } + + @Test + fun `a phrase containing a word that is not on the list is refused`() { + val real = Wallet.create(words).phrase.split(" ").toMutableList() + real[3] = "flamenet" + assertFailsWith { + Wallet.import(real.joinToString(" "), words) + } + } + + /** 🔴 An imported phrase gives back exactly the wallet it names. */ + @Test + fun `importing a phrase restores the same addresses`() { + val made = Wallet.create(words) + val imported = Wallet.import(made.phrase, words) + assertContentEquals(made.entropy, imported) + assertEquals( + Wallet.account(made.entropy, words).receive(0).address, + Wallet.account(imported, words).receive(0).address, + ) + } + + // ---- The counter ----------------------------------------------------- + + /** + * ⚠️ A restored wallet resumes at the index it was left at, when one is + * known. Resuming at zero would hand out addresses somebody has already + * been paid at, undoing the one linkability the wallet can prevent. + */ + @Test + fun `an account can resume from a stored index`() { + val made = Wallet.create(words) + val resumed = Wallet.account(made.entropy, words, nextIndex = 5) + assertEquals(5L, resumed.nextIndex) + val (receive, _) = resumed.next() + assertEquals( + Wallet.account(made.entropy, words).receive(5).address, + receive.address, + ) + } + + // ---- The BIP-39 passphrase ------------------------------------------- + + /** + * 🔴 A BIP-39 passphrase silently names a DIFFERENT wallet. That is the + * standard's design and the sharpest edge in it: somebody who mistypes one + * does not get an error, they get an empty account. + */ + @Test + fun `a BIP-39 passphrase produces a different wallet from the same phrase`() { + val made = Wallet.create(words) + val plain = HdAccount(Wallet.seed(made.entropy, words)).receive(0).address + val guarded = HdAccount(Wallet.seed(made.entropy, words, "extra")).receive(0).address + assertNotEquals(plain, guarded) + } + + // ---- End to end ------------------------------------------------------ + + /** + * The whole promise, in one test: write these words down, and any + * implementation anywhere gives back these addresses. + */ + @Test + fun `a phrase written down restores the same wallet`() { + val made = Wallet.create(words, random = fixedRandom(0x11)) + val addresses = (0L..3L).map { + Wallet.account(made.entropy, words).receive(it).address + } + + // Somebody types the words into a fresh install. + val restored = Wallet.import(made.phrase, words) + val restoredAddresses = (0L..3L).map { + Wallet.account(restored, words).receive(it).address + } + + assertEquals(addresses, restoredAddresses) + assertTrue(addresses.all { HdAccount.looksLikeAddress(it) }) + } +}