Skip to content
Merged
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
Expand Up @@ -85,8 +85,11 @@ public class WalletStorage {
}
}

/// Retrieve a mnemonic keyed by wallet id.
public func retrieveMnemonic(for walletId: Data) throws -> String {
/// Retrieve the mnemonic UTF-8 bytes keyed by wallet id.
///
/// Returning raw bytes lets security-sensitive call sites avoid
/// materializing a Swift `String` unless they truly need one.
public func retrieveMnemonicUTF8Bytes(for walletId: Data) throws -> Data {
let account = perWalletMnemonicAccount(for: walletId)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
Expand All @@ -104,14 +107,39 @@ public class WalletStorage {
guard status == errSecSuccess else {
throw WalletStorageError.keychainError(status)
}
guard let data = result as? Data,
let mnemonic = String(data: data, encoding: .utf8),
!mnemonic.isEmpty else {
guard let data = result as? Data, !data.isEmpty else {
throw WalletStorageError.mnemonicNotFound
}
return data
}

/// Retrieve a mnemonic keyed by wallet id.
public func retrieveMnemonic(for walletId: Data) throws -> String {
let data = try retrieveMnemonicUTF8Bytes(for: walletId)
guard let mnemonic = String(data: data, encoding: .utf8), !mnemonic.isEmpty else {
throw WalletStorageError.mnemonicNotFound
}
return mnemonic
}

/// Cheap existence check used by signer preflight paths.
///
/// Unlike `retrieveMnemonic(...)`, this does not materialize the
/// mnemonic bytes into Swift heap objects.
public func hasMnemonic(for walletId: Data) -> Bool {
let account = perWalletMnemonicAccount(for: walletId)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainService,
kSecAttrAccount as String: account,
kSecMatchLimit as String: kSecMatchLimitOne,
kSecReturnAttributes as String: true
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
return status == errSecSuccess
}
Comment on lines +125 to +141
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Preflight hasMnemonic and runtime retrieveMnemonicUTF8Bytes disagree on the "empty stored data" case.

retrieveMnemonicUTF8Bytes(for:) throws mnemonicNotFound not just on errSecItemNotFound but also when the keychain returns a Data whose isEmpty is true (Lines 110-112). hasMnemonic(for:) only checks status == errSecSuccess (Line 140) — it doesn't request the bytes and doesn't validate any size attribute. So a corrupt-or-zero-byte keychain row makes hasMnemonic return true while the actual signing path then fails with mnemonicNotFound, which surfaces in KeychainSigner.canSign as a "yes I can sign" lie followed by a sign-time error.

Two reasonable fixes:

  1. Single source of truth, no plaintext materialized. Inspect the persistent-data attribute size during the existence check (e.g., include kSecReturnAttributes with kSecAttrSize as String or use kSecReturnPersistentRef + a follow-up data length query) and treat size==0 as "no mnemonic". This keeps hasMnemonic plaintext-free.
  2. Fetch the bytes anyway and zero-check. Pragmatically simpler — it does materialize the Data, but only on the rare preflight callers, and it removes the disagreement entirely.

Empty rows shouldn't appear in practice (storeMnemonic writes Data(mnemonic.utf8) from a non-empty source), but since retrieveMnemonicUTF8Bytes actively guards the case, the two APIs should agree on what "has" means.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/swift-sdk/Sources/SwiftDashSDK/Core/Wallet/WalletStorage.swift`
around lines 125 - 141, hasMnemonic currently only checks SecItemCopyMatching
status and can return true for an empty stored value, causing a mismatch with
retrieveMnemonicUTF8Bytes which treats zero-length data as missing. Update
hasMnemonic(for:) to request the stored data (use kSecReturnData /
SecItemCopyMatching) and treat missing status or returned Data.isEmpty as false;
return true only when SecItemCopyMatching succeeds and the Data length > 0.
Reference functions: hasMnemonic(for:), retrieveMnemonicUTF8Bytes(for:), and
storeMnemonic(...) to ensure behavior stays consistent with how mnemonics are
stored.


/// Delete a mnemonic keyed by wallet id. Idempotent.
public func deleteMnemonic(for walletId: Data) throws {
let account = perWalletMnemonicAccount(for: walletId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -351,9 +351,9 @@ public final class KeychainSigner: Signer, @unchecked Sendable {
else {
return false
}
// `WalletStorage.retrieveMnemonic` throws on miss;
// a successful return is sufficient to confirm presence.
return (try? WalletStorage().retrieveMnemonic(for: resolved.walletId)) != nil
// Existence check only — do NOT materialize the mnemonic
// bytes on the preflight path.
return WalletStorage().hasMnemonic(for: resolved.walletId)
}

var found = false
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,65 @@
import DashSDKFFI
import Foundation
import Security

// MARK: - MnemonicResolver

private func scrubBytes(_ bytes: inout [UInt8]) {
bytes.withUnsafeMutableBufferPointer { buffer in
guard let base = buffer.baseAddress else { return }
memset_s(base, buffer.count, 0, buffer.count)
}
}

/// Best-effort in-memory obfuscation for mnemonic UTF-8 bytes while
/// they sit on the Swift heap between the Keychain read and the final
/// copy into Rust's `Zeroizing` buffer.
private final class MaskedMnemonicUTF8 {
private var maskedBytes: [UInt8]
private var maskBytes: [UInt8]

init(plaintextUTF8Bytes: Data) throws {
var plaintext = [UInt8](plaintextUTF8Bytes)
guard !plaintext.isEmpty else {
throw WalletStorageError.mnemonicNotFound
}

var localMask = [UInt8](repeating: 0, count: plaintext.count)
let randomStatus = localMask.withUnsafeMutableBufferPointer { buffer -> Int32 in
guard let base = buffer.baseAddress else { return errSecSuccess }
return SecRandomCopyBytes(kSecRandomDefault, buffer.count, base)
}
guard randomStatus == errSecSuccess else {
scrubBytes(&plaintext)
scrubBytes(&localMask)
throw WalletStorageError.keychainError(randomStatus)
}

var localMasked = [UInt8](repeating: 0, count: plaintext.count)
for i in plaintext.indices {
localMasked[i] = plaintext[i] ^ localMask[i]
}

scrubBytes(&plaintext)
self.maskedBytes = localMasked
self.maskBytes = localMask
}

deinit {
scrubBytes(&maskedBytes)
scrubBytes(&maskBytes)
}

func withDeobfuscatedBytes<R>(_ body: (UnsafeBufferPointer<UInt8>) -> R) -> R {
var plaintext = [UInt8](repeating: 0, count: maskedBytes.count)
for i in maskedBytes.indices {
plaintext[i] = maskedBytes[i] ^ maskBytes[i]
}
defer { scrubBytes(&plaintext) }
return plaintext.withUnsafeBufferPointer(body)
}
}

/// Swift bridge backing the Rust-side `MnemonicResolverHandle`.
///
/// The Rust derivation loop in
Expand All @@ -12,9 +69,9 @@ import Foundation
/// into Swift via this resolver to fetch the BIP-39 mnemonic for
/// the wallet whose identity keys it's deriving. The mnemonic is
/// copied directly into a Rust-owned `Zeroizing` stack buffer; it
/// never round-trips back to Swift after this single read, and
/// the Swift `String` from `WalletStorage.retrieveMnemonic` falls
/// out of scope at the end of the trampoline.
/// never round-trips back to Swift after this single read. On the
/// Swift side the bytes are masked while idle, then deobfuscated only
/// long enough to copy into the FFI output buffer.
///
/// # Lifetime contract
///
Expand Down Expand Up @@ -85,9 +142,9 @@ public final class MnemonicResolver: @unchecked Sendable {
outCapacity: Int,
outLen: UnsafeMutablePointer<Int>
) -> MnemonicResolverResult {
let mnemonic: String
let mnemonicUTF8Bytes: Data
do {
mnemonic = try storage.retrieveMnemonic(for: walletId)
mnemonicUTF8Bytes = try storage.retrieveMnemonicUTF8Bytes(for: walletId)
} catch WalletStorageError.mnemonicNotFound {
// Distinct "this wallet has no stored mnemonic" case
// — Rust surfaces this as the recoverable
Expand All @@ -105,18 +162,28 @@ public final class MnemonicResolver: @unchecked Sendable {
return .other
}

// `withCString` materializes a null-terminated UTF-8 byte
// sequence whose lifetime ends with the closure. We copy
// (excluding the trailing NUL) into the Rust-owned
// buffer; the source bytes drop with the Swift `String`
// at the end of `resolve`.
return mnemonic.withCString { srcPtr -> MnemonicResolverResult in
let mnemonicLen = strlen(srcPtr)
let maskedMnemonic: MaskedMnemonicUTF8
do {
maskedMnemonic = try MaskedMnemonicUTF8(plaintextUTF8Bytes: mnemonicUTF8Bytes)
} catch {
return .other
}

return maskedMnemonic.withDeobfuscatedBytes { bytes -> MnemonicResolverResult in
let mnemonicLen = bytes.count
// Need room for the data plus a trailing NUL byte.
guard mnemonicLen + 1 <= outCapacity else {
return .bufferTooSmall
}
outBuffer.update(from: srcPtr, count: mnemonicLen)
guard let srcBase = bytes.baseAddress else {
return .other
}
if bytes.contains(0) {
return .other
}
srcBase.withMemoryRebound(to: CChar.self, capacity: mnemonicLen) { srcPtr in
outBuffer.update(from: srcPtr, count: mnemonicLen)
}
// Explicit NUL terminator — defensive, the Rust side
// works off `out_len` not strlen but matching the
// wire contract is cheap insurance.
Expand Down Expand Up @@ -263,14 +330,13 @@ public final class IdentityKeyPersister: @unchecked Sendable {
let publicKeyHashHex = publicKeyHashData
.map { String(format: "%02x", $0) }
.joined()
// The 32 raw private bytes have to enter Swift `Data` to
// hand to `KeychainManager.storeIdentityPrivateKey`, which
// takes `Data` (Keychain APIs are iOS-only and Rust can't
// call SecItemAdd directly). Swift `Data` cannot be
// securely zeroed, so we keep this allocation as the only
// place the secret bytes touch the Swift heap and let it
// drop with the function frame as soon as Keychain has
// taken its copy.
// The 32 raw private bytes have to enter Swift `Data` to hand
// to `KeychainManager.storeIdentityPrivateKey`, which takes
// `Data` (Keychain APIs are iOS-only and Rust can't call
// `SecItemAdd` directly). Swift `Data` cannot be securely
// zeroed, so we keep this allocation as the only place the
// secret bytes touch the Swift heap and let it drop with the
// function frame as soon as Keychain has taken its copy.
//
// Earlier revisions also hex-encoded the private bytes
// into a `String` to call
Expand Down
51 changes: 40 additions & 11 deletions packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/Mnemonic.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import Foundation
import DashSDKFFI

private func scrubMnemonicBytes(_ bytes: inout [UInt8]) {
bytes.withUnsafeMutableBufferPointer { buffer in
guard let base = buffer.baseAddress else { return }
memset_s(base, buffer.count, 0, buffer.count)
}
}

/// Utility class for mnemonic operations
public class Mnemonic {

Expand Down Expand Up @@ -59,27 +66,49 @@ public class Mnemonic {
/// - passphrase: Optional BIP39 passphrase
/// - Returns: The seed data (typically 64 bytes)
public static func toSeed(mnemonic: String, passphrase: String? = nil) throws -> Data {
try toSeed(mnemonicUTF8Bytes: Data(mnemonic.utf8), passphrase: passphrase)
}

/// Convert mnemonic UTF-8 bytes to seed without first
/// materializing a Swift `String` at the call site.
public static func toSeed(mnemonicUTF8Bytes: Data, passphrase: String? = nil) throws -> Data {
guard !mnemonicUTF8Bytes.isEmpty else {
throw KeyWalletError.invalidInput("Mnemonic must not be empty")
}

var error = FFIError()
var seed = Data(count: 64)
var seedLen: size_t = 64
var mnemonicBytes = [UInt8](mnemonicUTF8Bytes)
guard !mnemonicBytes.contains(0) else {
scrubMnemonicBytes(&mnemonicBytes)
throw KeyWalletError.invalidInput("Mnemonic bytes must not contain NUL")
}
mnemonicBytes.append(0)
Comment on lines +82 to +87
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

mnemonicBytes.append(0) may reallocate, leaving the prior plaintext buffer un-scrubbed.

var mnemonicBytes = [UInt8](mnemonicUTF8Bytes) (Line 82) initializes the array at exact size, so capacity == count. The subsequent mnemonicBytes.append(0) (Line 87) will then trigger a reallocation: Swift grows the storage, copies the plaintext into the new buffer, and frees the old one without zeroing it. The defer { scrubMnemonicBytes(&mnemonicBytes) } at Line 111 only scrubs the current (post-realloc) buffer; the original buffer with the plaintext mnemonic is returned to the allocator dirty.

Reserve the final capacity up front so no reallocation occurs:

🔒 Proposed fix
-        var mnemonicBytes = [UInt8](mnemonicUTF8Bytes)
-        guard !mnemonicBytes.contains(0) else {
-            scrubMnemonicBytes(&mnemonicBytes)
-            throw KeyWalletError.invalidInput("Mnemonic bytes must not contain NUL")
-        }
-        mnemonicBytes.append(0)
+        var mnemonicBytes = [UInt8]()
+        mnemonicBytes.reserveCapacity(mnemonicUTF8Bytes.count + 1)
+        mnemonicBytes.append(contentsOf: mnemonicUTF8Bytes)
+        guard !mnemonicBytes.contains(0) else {
+            scrubMnemonicBytes(&mnemonicBytes)
+            throw KeyWalletError.invalidInput("Mnemonic bytes must not contain NUL")
+        }
+        mnemonicBytes.append(0)

The same pattern would also be worth applying inside MaskedMnemonicUTF8.init (MnemonicResolverAndPersister.swift), where var plaintext = [UInt8](plaintextUTF8Bytes) is read-only after init and so does not hit this pitfall — but it's a useful invariant to keep consistent.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
var mnemonicBytes = [UInt8](mnemonicUTF8Bytes)
guard !mnemonicBytes.contains(0) else {
scrubMnemonicBytes(&mnemonicBytes)
throw KeyWalletError.invalidInput("Mnemonic bytes must not contain NUL")
}
mnemonicBytes.append(0)
var mnemonicBytes = [UInt8]()
mnemonicBytes.reserveCapacity(mnemonicUTF8Bytes.count + 1)
mnemonicBytes.append(contentsOf: mnemonicUTF8Bytes)
guard !mnemonicBytes.contains(0) else {
scrubMnemonicBytes(&mnemonicBytes)
throw KeyWalletError.invalidInput("Mnemonic bytes must not contain NUL")
}
mnemonicBytes.append(0)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/Mnemonic.swift` around
lines 82 - 87, The code appends a NUL to mnemonicBytes which may reallocate and
leave the original plaintext buffer unsanitized; before calling
mnemonicBytes.append(0) in Mnemonic.swift, reserve space to avoid reallocation
(e.g. call mnemonicBytes.reserveCapacity(mnemonicBytes.count + 1) or allocate
the array with extra capacity) so scrubMnemonicBytes(&mnemonicBytes) in the
defer will wipe the only buffer containing plaintext; apply the same reservation
pattern inside MaskedMnemonicUTF8.init (referencing the plaintext variable in
MnemonicResolverAndPersister.swift) to ensure no intermediate reallocations
leave plaintext in freed memory.


let success = mnemonic.withCString { mnemonicCStr in
seed.withUnsafeMutableBytes { seedBytes in
let seedPtr = seedBytes.bindMemory(to: UInt8.self).baseAddress

if let passphrase = passphrase {
return passphrase.withCString { passphraseCStr in
mnemonic_to_seed(mnemonicCStr, passphraseCStr,
seedPtr, &seedLen, &error)
let success = mnemonicBytes.withUnsafeBufferPointer { mnemonicBuf in
guard let mnemonicBase = mnemonicBuf.baseAddress else {
return false
}
return mnemonicBase.withMemoryRebound(to: CChar.self, capacity: mnemonicBuf.count) { mnemonicCStr in
seed.withUnsafeMutableBytes { seedBytes in
let seedPtr = seedBytes.bindMemory(to: UInt8.self).baseAddress

if let passphrase = passphrase {
return passphrase.withCString { passphraseCStr in
mnemonic_to_seed(mnemonicCStr, passphraseCStr,
seedPtr, &seedLen, &error)
}
} else {
return mnemonic_to_seed(mnemonicCStr, nil,
seedPtr, &seedLen, &error)
}
} else {
return mnemonic_to_seed(mnemonicCStr, nil,
seedPtr, &seedLen, &error)
}
}
}

defer {
scrubMnemonicBytes(&mnemonicBytes)
if error.message != nil {
error_message_free(error.message)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -752,21 +752,22 @@ public class PlatformWalletPersistenceHandler {
}
let network: KeyWalletNetwork = persistentWallet.network?.toKeyWalletNetwork() ?? .testnet

// 2. Fetch the mnemonic for this wallet from the keychain.
// WalletStorage stores it under `wallet.mnemonic.<hex>`
// in the unified `org.dashfoundation.wallet` service.
let mnemonic: String
// 2. Fetch the mnemonic UTF-8 bytes for this wallet from the
// keychain. Keep the call site off Swift `String` so the
// plaintext phrase does not live in higher-level heap
// objects longer than necessary.
let mnemonicUTF8Bytes: Data
do {
mnemonic = try WalletStorage().retrieveMnemonic(for: walletId)
mnemonicUTF8Bytes = try WalletStorage().retrieveMnemonicUTF8Bytes(for: walletId)
} catch {
print("⚠️ deriveAndStoreIdentityKey: mnemonic missing for wallet \(walletId.prefix(4).toHexString())…: \(error.localizedDescription)")
return nil
}

// 3. Mnemonic → 64-byte BIP39 seed.
// 3. Mnemonic UTF-8 bytes → 64-byte BIP39 seed.
let seed: Data
do {
seed = try Mnemonic.toSeed(mnemonic: mnemonic)
seed = try Mnemonic.toSeed(mnemonicUTF8Bytes: mnemonicUTF8Bytes)
} catch {
print("⚠️ deriveAndStoreIdentityKey: mnemonic-to-seed failed: \(error.localizedDescription)")
return nil
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,16 @@ class PlatformWalletTests: XCTestCase {
XCTAssertNotNil(wallet, "Wallet should be created from mnemonic with passphrase")
}

func testMnemonicToSeedFromUTF8BytesMatchesStringPath() throws {
let fromString = try Mnemonic.toSeed(mnemonic: testMnemonic, passphrase: "test123")
let fromBytes = try Mnemonic.toSeed(
mnemonicUTF8Bytes: Data(testMnemonic.utf8),
passphrase: "test123"
)

XCTAssertEqual(fromBytes, fromString, "Byte-based mnemonic derivation should match the existing string path")
}

// MARK: - Identity Manager Tests

func testGetIdentityManager() throws {
Expand Down
Loading