Wallet engine primitives in Kotlin: BIP-39, Base58, SLIP-0010 #71

Merged
paulhitt merged 3 commits from feat/wallet-engine-primitives into main 2026-09-04 20:49:33 +00:00
paulhitt commented 2026-09-04 18:12:50 +00:00 (Migrated from gitlab.com)

The foundation Files and Wallet rest on. PARITY.md was right that this is a
build rather than a port of a screen — these had no Kotlin counterpart at all.

Pinned to the STANDARDS, not to the Swift engine. BIP-39's own vectors,
SLIP-0010's own ed25519 vectors, and the published word-list SHA-256 — the same
things the Swift side is held to. Checking one implementation against the other
would only prove 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.

Every failure this file guards 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.

Two Kotlin-specific decisions, both money-relevant:

PBKDF2-HMAC-SHA512 is hand-written over Mac rather than taken from
SecretKeyFactory("PBKDF2WithHmacSHA512"). The JCA path takes a CharArray and
the provider decides how to encode it — AccountKdf already carries that caveat
for the account KDF. For a wallet the consequence is worse than a failed
sign-in: a phrase containing anything outside ASCII would stretch to different
bytes under a different provider and derive a different, empty, perfectly valid
wallet with no error anywhere. Taking the bytes ourselves removes the provider
from the decision. (It is also absent below API 26.)

Base58 is the digit loop, not BigInteger. The obvious Kotlin version is
BigInteger(1, bytes).toString(58) with a remapped alphabet, and it is wrong in
the one way that matters: BigInteger discards leading zeros, so a key beginning
0x00 loses a character and encodes to another key's address.

One test was WRONG on the first run and the implementation was right. The
all-ones 24-word vector carried the seed from a different BIP-39 vector. It was
resolved by computing the value independently — PBKDF2-HMAC-SHA512, NFKD, 2048
rounds — rather than by adopting whatever the implementation produced, and the
file says so. A pinned value taken from the code under test pins the bug.

The vendored word list is byte-identical to the iOS one and verified on every
load, not once at build: it ships inside the app and a check that only ran in CI
would not notice one that arrived corrupted.

23 tests. App builds, every JVM suite green.

Co-Authored-By: Claude Opus 5 noreply@anthropic.com

The foundation Files and Wallet rest on. PARITY.md was right that this is a build rather than a port of a screen — these had no Kotlin counterpart at all. Pinned to the STANDARDS, not to the Swift engine. BIP-39's own vectors, SLIP-0010's own ed25519 vectors, and the published word-list SHA-256 — the same things the Swift side is held to. Checking one implementation against the other would only prove 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. Every failure this file guards 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. Two Kotlin-specific decisions, both money-relevant: PBKDF2-HMAC-SHA512 is hand-written over `Mac` rather than taken from `SecretKeyFactory("PBKDF2WithHmacSHA512")`. The JCA path takes a CharArray and the provider decides how to encode it — AccountKdf already carries that caveat for the account KDF. For a wallet the consequence is worse than a failed sign-in: a phrase containing anything outside ASCII would stretch to different bytes under a different provider and derive a different, empty, perfectly valid wallet with no error anywhere. Taking the bytes ourselves removes the provider from the decision. (It is also absent below API 26.) Base58 is the digit loop, not `BigInteger`. The obvious Kotlin version is `BigInteger(1, bytes).toString(58)` with a remapped alphabet, and it is wrong in the one way that matters: BigInteger discards leading zeros, so a key beginning 0x00 loses a character and encodes to another key's address. One test was WRONG on the first run and the implementation was right. The all-ones 24-word vector carried the seed from a different BIP-39 vector. It was resolved by computing the value independently — PBKDF2-HMAC-SHA512, NFKD, 2048 rounds — rather than by adopting whatever the implementation produced, and the file says so. A pinned value taken from the code under test pins the bug. The vendored word list is byte-identical to the iOS one and verified on every load, not once at build: it ships inside the app and a check that only ran in CI would not notice one that arrived corrupted. 23 tests. App builds, every JVM suite green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
paulhitt commented 2026-09-04 18:12:50 +00:00 (Migrated from gitlab.com)

assigned to @paulhitt

assigned to @paulhitt
paulhitt (Migrated from gitlab.com) changed target branch from feat/local-salt-and-multihoming to main 2026-09-04 20:49:31 +00:00
paulhitt commented 2026-09-04 20:49:33 +00:00 (Migrated from gitlab.com)

mentioned in commit 8ea8341f03

mentioned in commit 8ea8341f03d909e40ce3205ca641da4a86b07b1c
paulhitt (Migrated from gitlab.com) merged commit 8ea8341f03 into main 2026-09-04 20:49:33 +00:00
paulhitt commented 2026-09-09 19:01:32 +00:00 (Migrated from gitlab.com)

changed the description

changed the description
Sign in to join this conversation.