Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import CommonCrypto
import Foundation
import DashSDKFFI

Expand Down Expand Up @@ -115,7 +116,20 @@ public class Mnemonic {
}

guard success else {
throw KeyWalletError(ffiError: error)
let ffiFailure = KeyWalletError(ffiError: error)
// `mnemonic_to_seed` parses with a hardcoded English wordlist, so
// a phrase in any other supported language fails with
// `invalidMnemonic` even though `validate` (which tries every
// wordlist) accepts it. BIP-39 seed derivation itself is
// wordlist-independent — PBKDF2-HMAC-SHA512 over the NFKD
// sentence — so derive it directly for phrases that validate.
if case .invalidMnemonic = ffiFailure,
let fallbackSeed = multiLanguageSeed(
mnemonicUTF8Bytes: mnemonicUTF8Bytes,
passphrase: passphrase) {
return fallbackSeed
}
throw ffiFailure
}

// Resize if necessary
Expand All @@ -126,6 +140,52 @@ public class Mnemonic {
return seed
}

/// BIP-39 seed for a phrase in any supported wordlist: PBKDF2-HMAC-SHA512
/// over the NFKD-canonicalized sentence, salt `"mnemonic" + NFKD(passphrase)`,
/// 2048 rounds, 64 bytes — the derivation every BIP-39 wallet (including
/// DashSync and Electrum) uses, which never needs the wordlist itself.
/// Returns nil when the phrase does not validate against any supported
/// language (callers surface the original FFI error).
private static func multiLanguageSeed(mnemonicUTF8Bytes: Data, passphrase: String?) -> Data? {
let phrase = String(decoding: mnemonicUTF8Bytes, as: UTF8.self)
guard validate(phrase) else { return nil }

// `normalizePhrase` (NFKD + lowercase + single-space separators)
// reconstructs the canonical BIP-39 sentence for a validated phrase:
// wordlists ship lowercase NFKD, and NFKD maps the ideographic space
// to ASCII space, so this matches the seed the FFI derives for
// English phrases and the seed other wallets derive for this phrase.
var password = [UInt8](normalizePhrase(phrase).utf8)
defer { scrubMnemonicBytes(&password) }
// The passphrase is only NFKD-normalized — case and punctuation are
// significant in BIP-39 passphrases. It is scrubbed like the phrase:
// a passphrase can be as sensitive as the mnemonic itself.
var salt = [UInt8](
("mnemonic" + (passphrase ?? "")).decomposedStringWithCompatibilityMapping.utf8)
Comment on lines +163 to +164

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']

defer { scrubMnemonicBytes(&salt) }

var derived = [UInt8](repeating: 0, count: 64)
let status = password.withUnsafeBufferPointer { passwordBuf in
salt.withUnsafeBufferPointer { saltBuf in
derived.withUnsafeMutableBufferPointer { derivedBuf in
CCKeyDerivationPBKDF(
CCPBKDFAlgorithm(kCCPBKDF2),
UnsafeRawPointer(passwordBuf.baseAddress)?
.assumingMemoryBound(to: Int8.self),
passwordBuf.count,
saltBuf.baseAddress,
saltBuf.count,
CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA512),
2048,
derivedBuf.baseAddress,
derivedBuf.count)
}
}
}
guard status == kCCSuccess else { return nil }
return Data(derived)
}

/// Get word count from a mnemonic phrase
/// - Parameter mnemonic: The mnemonic phrase
/// - Returns: The number of words
Expand Down
31 changes: 25 additions & 6 deletions packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/Wallet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,25 @@ public class Wallet {
}
}

guard let handle = walletPtr else {
throw KeyWalletError(ffiError: error)
}

self.handle = handle
if let handle = walletPtr {
self.handle = handle
self.ownsHandle = true
return
}

let ffiFailure = KeyWalletError(ffiError: error)
// `wallet_create_from_mnemonic` parses with a hardcoded English
// wordlist. A phrase in any other supported language still validates
// (`mnemonic_validate` tries every wordlist), and wallet ids and
// derived keys are determined by the BIP-39 seed alone, so build the
// identical wallet from the seed instead. `Mnemonic.toSeed` handles
// every supported language.
guard case .invalidMnemonic = ffiFailure, Mnemonic.validate(mnemonic) else {
throw ffiFailure
}
let seed = try Mnemonic.toSeed(mnemonic: mnemonic)
self.handle = try Self.createHandle(
seed: seed, network: network, accountOptions: accountOptions)
Comment on lines +85 to +87

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']

self.ownsHandle = true
}

Expand All @@ -81,8 +95,13 @@ public class Wallet {
/// - accountOptions: Account creation options
public init(seed: Data, network: Network = .mainnet,
accountOptions: AccountCreationOption = .default) throws {
self.handle = try Self.createHandle(
seed: seed, network: network, accountOptions: accountOptions)
self.ownsHandle = true
}

private static func createHandle(seed: Data, network: Network,
accountOptions: AccountCreationOption) throws -> OpaquePointer {
var error = FFIError()
let walletPtr: OpaquePointer? = seed.withUnsafeBytes { seedBytes in
let seedPtr = seedBytes.bindMemory(to: UInt8.self).baseAddress
Expand Down Expand Up @@ -116,7 +135,7 @@ public class Wallet {
throw KeyWalletError(ffiError: error)
}

self.handle = handle
return handle
}

/// Create a watch-only wallet from extended public key
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import CommonCrypto
import DashSDKFFI
import XCTest
@testable import SwiftDashSDK

/// Regression tests for non-English BIP-39 mnemonics (dashwallet-ios field
/// report, 2026-08-22): a French 12-word phrase validated fine
/// (`mnemonic_validate` tries every wordlist) but `Mnemonic.toSeed` and
/// `Wallet(mnemonic:)` failed with "Invalid Mnemonic: mnemonic contains an
/// unknown word (word 0)" because the key-wallet FFI they bind to parses with
/// a hardcoded English wordlist. That broke the DashSync→SwiftDashSDK upgrade
/// migration and "Import from Phrase" for every legacy localized wallet.
///
/// Expected seeds were generated with an independent oracle
/// (Python: `hashlib.pbkdf2_hmac('sha512', NFKD(phrase), NFKD('mnemonic'+pass),
/// 2048, 64)`), which reproduces the official BIP-39 English test vectors and
/// the derivation Electrum/DashSync use — the derivation that recovers real
/// user funds.
final class NonEnglishMnemonicTests: XCTestCase {

// French mnemonic for entropy 000102030405060708090a0b0c0d0e0f, words
// from the official BIP-39 French wordlist (NFKD-encoded, as published).
private static let frenchPhrase =
"abaisser agréable inductif agréable éligible achat bolide boucle amateur exister dérober bloquer"

// The same phrase with precomposed accents (NFC) — what an iOS keyboard
// actually produces. Seed derivation must treat both forms identically.
private static let frenchPhraseNFC =
NonEnglishMnemonicTests.frenchPhrase.precomposedStringWithCanonicalMapping

private static let frenchSeedHex =
"b70232fad2698ee7236b5f789e1566157f41e9b0a22b4dfa0c3325172a6fd851" +
"3e0d552a12c335737275847d5b25a24bfaad97bdb4d98541901d3bd2a9cbfcf1"

private static let frenchSeedTrezorHex =
"984ede340ea47fbf2794c9dcde0c4e2e92bf16a5e172083e0c734835c33c6f66" +
"7a2c635ce38b0819fab9397c683692cc6f28523072d80b96e031022bbb532992"

// Official BIP-39 English test vector (entropy 00…00, passphrase-less
// seed cross-checked against the same oracle) — guards that the English
// fast path is unchanged.
private static let englishPhrase =
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"

private static let englishSeedHex =
"5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc1" +
"9a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4"

private static let englishSeedTrezorHex =
"c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e5349553" +
"1f09a6987599d18264c1e1c92f2cf141630c7a3c4ab7c81b2f001698e7463b04"

private func hex(_ data: Data) -> String {
data.map { String(format: "%02x", $0) }.joined()
}

// MARK: - Baseline: validation already accepts non-English phrases

func testValidateAcceptsFrenchPhrase() {
XCTAssertTrue(Mnemonic.validate(Self.frenchPhrase))
XCTAssertTrue(Mnemonic.validate(Self.frenchPhraseNFC))
}

// MARK: - Seed derivation must accept every language validation accepts

func testToSeedFrenchMatchesReferenceVector() throws {
let seed = try Mnemonic.toSeed(mnemonic: Self.frenchPhrase)
XCTAssertEqual(hex(seed), Self.frenchSeedHex)
}

func testToSeedFrenchNFCInputMatchesSameSeed() throws {
let seed = try Mnemonic.toSeed(mnemonic: Self.frenchPhraseNFC)
XCTAssertEqual(hex(seed), Self.frenchSeedHex)
}

func testToSeedFrenchWithPassphrase() throws {
let seed = try Mnemonic.toSeed(mnemonic: Self.frenchPhrase, passphrase: "TREZOR")
XCTAssertEqual(hex(seed), Self.frenchSeedTrezorHex)
}

// MARK: - Wallet construction must accept every language validation accepts

func testWalletFromFrenchMnemonicDerivesSeedConsistentIds() throws {
// `Wallet(mnemonic:)` previously threw KeyWalletError.invalidMnemonic
// ("unknown word (word 0)") for any non-English phrase. It must
// produce the same wallet as building from the phrase's BIP-39 seed.
let fromMnemonic = try Wallet(mnemonic: Self.frenchPhrase, network: .mainnet)
let fromSeed = try Wallet(
seed: Data(Self.frenchSeedHex.hexToBytes()), network: .mainnet)
XCTAssertEqual(try fromMnemonic.id, try fromSeed.id)

// Wallet ids are network-scoped: same phrase, distinct id per network.
let testnet = try Wallet(mnemonic: Self.frenchPhrase, network: .testnet)
XCTAssertNotEqual(try fromMnemonic.id, try testnet.id)
}

func testWalletFromFrenchMnemonicNFCInputSameWallet() throws {
let nfc = try Wallet(mnemonic: Self.frenchPhraseNFC, network: .mainnet)
let nfkd = try Wallet(mnemonic: Self.frenchPhrase, network: .mainnet)
XCTAssertEqual(try nfc.id, try nfkd.id)
}

// MARK: - English fast path is unchanged

func testToSeedEnglishOfficialVectorUnchanged() throws {
let seed = try Mnemonic.toSeed(mnemonic: Self.englishPhrase)
XCTAssertEqual(hex(seed), Self.englishSeedHex)

let trezor = try Mnemonic.toSeed(mnemonic: Self.englishPhrase, passphrase: "TREZOR")
XCTAssertEqual(hex(trezor), Self.englishSeedTrezorHex)
}

func testWalletFromEnglishMnemonicMatchesSeedWallet() throws {
let fromMnemonic = try Wallet(mnemonic: Self.englishPhrase, network: .mainnet)
let fromSeed = try Wallet(
seed: Data(Self.englishSeedHex.hexToBytes()), network: .mainnet)
XCTAssertEqual(try fromMnemonic.id, try fromSeed.id)
}

// MARK: - Cross-implementation agreement

func testRustMultiLanguageDerivationAgreesWithReferenceSeed() throws {
// The platform wallet manager creates wallets through Rust's
// language-auto-detecting parse (`parse_mnemonic_any_language`) and
// rust-bip39's `to_seed`, while `Wallet(mnemonic:)`/`Mnemonic.toSeed`
// reach the same seed through the Swift fallback. If the two ever
// disagreed, wallet ids computed app-side would not match the wallets
// the manager creates. Derive the BIP-32 master key from the French
// phrase via the Rust path and compare with the master key computed
// directly from the reference seed (HMAC-SHA512 keyed "Bitcoin seed").
var secretKey = [UInt8](repeating: 0, count: 32)
var chainCode = [UInt8](repeating: 0, count: 32)
let result = Self.frenchPhrase.withCString { mnemonicPtr in
"m".withCString { pathPtr in
platform_wallet_derive_ext_priv_key_from_mnemonic(
mnemonicPtr, nil, Network.mainnet.ffiValue, pathPtr,
&secretKey, &chainCode, nil)
}
}
try result.check()

let seed = Data(Self.frenchSeedHex.hexToBytes())
var hmac = [UInt8](repeating: 0, count: Int(CC_SHA512_DIGEST_LENGTH))
let key = Array("Bitcoin seed".utf8)
seed.withUnsafeBytes { seedBytes in
CCHmac(CCHmacAlgorithm(kCCHmacAlgSHA512),
key, key.count,
seedBytes.baseAddress, seedBytes.count,
&hmac)
}
XCTAssertEqual(Array(secretKey), Array(hmac[0..<32]))
XCTAssertEqual(Array(chainCode), Array(hmac[32..<64]))
}

// MARK: - Invalid input still refused

func testToSeedGibberishStillThrows() {
XCTAssertThrowsError(
try Mnemonic.toSeed(mnemonic: "definitely not a bip39 phrase at all zz"))
}

func testWalletFromGibberishStillThrows() {
XCTAssertThrowsError(
try Wallet(mnemonic: "definitely not a bip39 phrase at all zz", network: .mainnet))
}
}

private extension String {
func hexToBytes() -> [UInt8] {
var bytes: [UInt8] = []
bytes.reserveCapacity(count / 2)
var index = startIndex
while index < endIndex {
let next = self.index(index, offsetBy: 2)
bytes.append(UInt8(self[index..<next], radix: 16)!)
index = next
}
return bytes
}
}
Loading