Skip to content

fix(swift-sdk): accept non-English BIP-39 mnemonics in seed derivation and wallet construction - #4454

Draft
PastaPastaPasta wants to merge 1 commit into
dashpay:v4.2-devfrom
PastaPastaPasta:fix/non-english-mnemonic-seed
Draft

fix(swift-sdk): accept non-English BIP-39 mnemonics in seed derivation and wallet construction#4454
PastaPastaPasta wants to merge 1 commit into
dashpay:v4.2-devfrom
PastaPastaPasta:fix/non-english-mnemonic-seed

Conversation

@PastaPastaPasta

Copy link
Copy Markdown
Member

Issue being fixed or feature implemented

Field report from dashwallet-ios testing (2026-08-22): a user's multi-year-old French 12-word recovery phrase could not be imported into the migrated app:

  • the DashSync→SwiftDashSDK upgrade migration silently failed, so the old wallet was "not detected" and the app opened a brand-new wallet;
  • "Import from Phrase" failed with Invalid Mnemonic: mnemonic contains an unknown word (word 0);
  • the same phrase restores fine in Dash Electrum.

Root cause: mnemonic_validate (key-wallet FFI) tries all 10 supported BIP-39 wordlists, but the two functions actually used for key material — mnemonic_to_seed and wallet_create_from_mnemonic — parse with hardcoded Language::English (pinned rust-dashcore rev, not fixable in this repo). So any non-English phrase passes Mnemonic.validate and then fails in Mnemonic.toSeed / Wallet(mnemonic:). In dashwallet-ios that breaks SwiftDashSDKKeyMigrator (seed sanity check throws → legacy wallet never imported), SwiftDashSDKHost.addWallet (wallet-id resolver), and the onboarding recover flow, for every legacy localized wallet.

What was done?

Swift-layer fallback in packages/swift-sdk, scoped to exactly the phrases the English-only FFI wrongly rejects:

  • Mnemonic.toSeed: when the FFI fails with invalidMnemonic and the phrase validates in some supported language, derive the BIP-39 seed directly — PBKDF2-HMAC-SHA512 over the NFKD-canonicalized sentence (normalizePhrase: NFKD + lowercase + single-space separators), salt "mnemonic" + NFKD(passphrase), 2048 rounds, 64 bytes. This is the wordlist-independent derivation the BIP-39 spec defines and what DashSync and Electrum compute — i.e. the seed that recovers the user's actual funds. Phrase and salt buffers are scrubbed after use.
  • Wallet(mnemonic:): same trigger; builds the wallet from the derived seed via a shared createHandle(seed:) (extracted from init(seed:), behavior-preserving). Wallet ids and derived keys are seed-determined, so the resulting wallet is identical to what a language-aware mnemonic parse would produce.
  • English phrases keep the existing FFI fast path unchanged; garbage input is still rejected (the fallback re-validates and otherwise rethrows the original FFI error).

The rest of the pipeline already auto-detects language in Rust (parse_mnemonic_any_language in rs-platform-wallet / rs-platform-wallet-ffi): PlatformWalletManager.createWallet(mnemonic:), the keychain signing resolver, and identity-key derivation. This change brings the two remaining app-hit entry points in line with them.

How Has This Been Tested?

New SwiftTests/SwiftDashSDKTests/NonEnglishMnemonicTests.swift (11 tests):

  • French vectors (entropy 000102…0f, official French wordlist) — seed with and without passphrase, matching an independent Python oracle (hashlib.pbkdf2_hmac over the NFKD sentence) that reproduces the official BIP-39 English test vectors;
  • NFC vs NFKD input equivalence (iOS keyboards type precomposed accents);
  • wallet construction from the French phrase: id equals the wallet built from the reference seed, and ids stay network-scoped;
  • cross-implementation agreement: the Rust language-auto-detecting path (platform_wallet_derive_ext_priv_key_from_mnemonicparse_mnemonic_any_language + rust-bip39 to_seed) derives the same BIP-32 master key as the reference seed, pinning that app-side wallet ids match manager-created wallets;
  • English official-vector guards (fast path unchanged) and invalid-input rejection.

Red→green verified locally: all 5 French tests failed before the fix with exactly the field-reported error; all 11 pass after. Full swift-sdk unit suite: 365 tests, 0 failures (macOS, hermetic). xcodebuild compile for iOS simulator clean.

Breaking Changes

None. Strictly additive: only inputs that previously threw now succeed; English behavior is byte-identical.

Known remaining gap (follow-up): WalletManager.addWallet(mnemonic:) and PlatformWallet.fromMnemonic(_:network:) bind to the same English-hardcoded FFI and still reject non-English phrases. Neither has a production caller today. The proper root fix is upstream in rust-dashcore (key-wallet-ffi), which would also let this fallback be removed.

Checklist:

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have added or updated relevant unit/integration/functional/e2e tests
  • I have added "!" to the title and described breaking changes in the corresponding section if my code contains any
  • I have made corresponding changes to the documentation if needed

🤖 Generated with Claude Code

…n and wallet construction

mnemonic_validate tries every supported wordlist, but the key-wallet FFI behind Mnemonic.toSeed and Wallet(mnemonic:) parses with a hardcoded English wordlist, so any French/Spanish/Japanese/... phrase that passed validation still failed with 'Invalid Mnemonic: mnemonic contains an unknown word (word 0)'. In dashwallet-ios this broke the DashSync upgrade migration (legacy localized wallets silently not imported) and 'Import from Phrase'.

Fall back when the English-only FFI rejects a phrase that validates in another language: derive the BIP-39 seed directly (PBKDF2-HMAC-SHA512 over the NFKD sentence, the wordlist-independent derivation DashSync and Electrum use) and build wallets from that seed - wallet ids and keys are seed-determined, so the result is identical. A regression test also pins that Rust's language-auto-detecting derivation path (parse_mnemonic_any_language) agrees with this seed, keeping app-computed wallet ids consistent with manager-created wallets.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@PastaPastaPasta, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 38 minutes

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: f83afc32-7fb1-4f7d-99b3-4e5c2dbfa5d2

📥 Commits

Reviewing files that changed from the base of the PR and between afc625b and cf2ac62.

📒 Files selected for processing (3)
  • packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/Mnemonic.swift
  • packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/Wallet.swift
  • packages/swift-sdk/SwiftTests/SwiftDashSDKTests/NonEnglishMnemonicTests.swift

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@thepastaclaw

thepastaclaw commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator

⛔ Blockers found — Opus deferred (commit cf2ac62)
Canonical validated blockers: 1

@PastaPastaPasta
PastaPastaPasta marked this pull request as draft August 22, 2026 16:15
@PastaPastaPasta

Copy link
Copy Markdown
Member Author

Re-planned per review: the Swift-layer fallback here fixes the symptom at the wrong layer of the stack. The root fix now lives in dashpay/rust-dashcore#981 (Mnemonic::from_phrase_any_language, routed through mnemonic_to_seed, wallet_create_from_mnemonic, the account derive-from-mnemonic exports, WalletManager::create_wallet_from_mnemonic, and FromStr).

Once #981 merges into rust-dashcore dev, this PR will be reworked to:

  1. bump the rust-dashcore pin in Cargo.toml/Cargo.lock,
  2. drop the Swift-side PBKDF2 fallback from Mnemonic.swift/Wallet.swift,
  3. keep NonEnglishMnemonicTests.swift — the same regression tests then exercise the fixed FFI directly (they were red against the current pin with exactly the field-reported error).

Converting to draft until then.


🤖 Posted autonomously by Claude on behalf of pasta.

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Preliminary review — Codex only

The multilingual BIP-39 derivation is correctly gated by full mnemonic validation and follows the required NFKD/PBKDF2-HMAC-SHA512 procedure. However, the wallet fallback changes mnemonic-created wallets into seed-backed wallets, and the new derivation path handles embedded-NUL passphrases differently from the existing FFI path.
Source: reviewer backends: gpt-5.6-sol (general, security-auditor, FFI-engineer); final verifier backend: gpt-5.6-sol. Orchestration-only, not reviewer evidence: openclaw-agent/cliproxy/gpt-5.6-sol.

Validated blockers were found in the Codex precheck. Opus is deferred until a fresh Codex revalidation clears the blocker gate.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (completed), gpt-5.6-sol — security-auditor (completed), gpt-5.6-sol — ffi-engineer (completed)
  • Verifier: gpt-5.6-sol — verifier
  • Sonnet: not run (deferred by blocker gate)

🔴 1 blocking | 🟡 1 suggestion(s)

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/Wallet.swift`:
- [BLOCKING] packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/Wallet.swift:85-87: Localized mnemonic initialization creates a seed wallet
  The fallback calls `wallet_create_from_seed[_with_options]`, which the pinned key-wallet implementation routes through `Wallet::from_seed` and stores as `WalletType::Seed`. The normal mnemonic constructor routes through `Wallet::from_mnemonic` and stores `WalletType::Mnemonic`. This difference is directly observable through the public `Wallet.hasMnemonic` property: a valid French phrase now constructs successfully but reports `false`, while an English phrase passed to the same initializer reports `true`. The variants also retain different recovery material and have different serialized representations, so this is not the identical wallet claimed by the comment and can cause backup or capability code to misclassify an imported recovery phrase. Keep construction on a language-aware mnemonic constructor path, such as by updating the pinned Rust implementation, rather than replacing mnemonic construction with seed construction.

In `packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/Mnemonic.swift`:
- [SUGGESTION] packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/Mnemonic.swift:163-164: Embedded-NUL passphrases diverge between Swift and Rust derivation
  The existing FFI path passes the passphrase through `withCString`, and pinned Rust reads it with `CStr::from_ptr`, so an embedded U+0000 terminates the passphrase and discards its suffix. The new non-English fallback converts the entire Swift string to UTF-8 with an explicit length, causing the NUL and suffix to participate in PBKDF2. Consequently, the same passphrase has language-dependent semantics, and the fallback can derive a different seed from the Rust implementation. BIP-39 defines the passphrase as normalized UTF-8 rather than a C string; either reject embedded NUL before selecting either path or change the Rust ABI to accept a pointer and explicit byte length.

Comment on lines +85 to +87
let seed = try Mnemonic.toSeed(mnemonic: mnemonic)
self.handle = try Self.createHandle(
seed: seed, network: network, accountOptions: accountOptions)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: Localized mnemonic initialization creates a seed wallet

The fallback calls wallet_create_from_seed[_with_options], which the pinned key-wallet implementation routes through Wallet::from_seed and stores as WalletType::Seed. The normal mnemonic constructor routes through Wallet::from_mnemonic and stores WalletType::Mnemonic. This difference is directly observable through the public Wallet.hasMnemonic property: a valid French phrase now constructs successfully but reports false, while an English phrase passed to the same initializer reports true. The variants also retain different recovery material and have different serialized representations, so this is not the identical wallet claimed by the comment and can cause backup or capability code to misclassify an imported recovery phrase. Keep construction on a language-aware mnemonic constructor path, such as by updating the pinned Rust implementation, rather than replacing mnemonic construction with seed construction.

source: ['codex']

Comment on lines +163 to +164
var salt = [UInt8](
("mnemonic" + (passphrase ?? "")).decomposedStringWithCompatibilityMapping.utf8)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Embedded-NUL passphrases diverge between Swift and Rust derivation

The existing FFI path passes the passphrase through withCString, and pinned Rust reads it with CStr::from_ptr, so an embedded U+0000 terminates the passphrase and discards its suffix. The new non-English fallback converts the entire Swift string to UTF-8 with an explicit length, causing the NUL and suffix to participate in PBKDF2. Consequently, the same passphrase has language-dependent semantics, and the fallback can derive a different seed from the Rust implementation. BIP-39 defines the passphrase as normalized UTF-8 rather than a C string; either reject embedded NUL before selecting either path or change the Rust ABI to accept a pointer and explicit byte length.

source: ['codex']

@PastaPastaPasta

Copy link
Copy Markdown
Member Author

Coordination update: the underlying bug is now fixed upstream twice over —

Revised plan for this PR: once #4455 lands, rebase this to drop the Swift-side PBKDF2 fallback entirely and keep only NonEnglishMnemonicTests.swift (the tests then exercise the fixed FFI directly). When the pin later advances past rust-dashcore#981, the ~16 platform call sites using the old two-argument from_phrase get their mechanical one-line updates and the duplicate parse_mnemonic_any_language helpers (e.g. rs-sdk-ffi/src/signer_simple.rs) can be retired.


🤖 Posted autonomously by Claude on behalf of pasta.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants