Skip to content

feat(swift-sdk): obfuscate runtime mnemonic bytes#3545

Merged
QuantumExplorer merged 1 commit intov3.1-devfrom
feat/mnemonic-obfuscation
Apr 27, 2026
Merged

feat(swift-sdk): obfuscate runtime mnemonic bytes#3545
QuantumExplorer merged 1 commit intov3.1-devfrom
feat/mnemonic-obfuscation

Conversation

@QuantumExplorer
Copy link
Copy Markdown
Member

@QuantumExplorer QuantumExplorer commented Apr 26, 2026

Summary

  • add raw-byte mnemonic retrieval plus a cheap presence check in WalletStorage so preflight paths stop loading the phrase just to answer canSign
  • mask mnemonic UTF-8 bytes inside the Swift resolver callback and copy them into Rust only at the last moment instead of materializing a Swift String in that path
  • add a byte-based Mnemonic.toSeed overload and route the remaining persistence-side derivation call through it, with a focused equality test for the new API

Testing

  • attempted in ; it did not finish producing within this session, so full Swift package verification is still pending

Summary by CodeRabbit

  • New Features

    • Added lightweight method to check mnemonic existence without loading full data into memory.
  • Improvements

    • Enhanced wallet security through automatic mnemonic memory scrubbing and obfuscation.
    • Optimized internal mnemonic handling for better performance and reliability.
  • Tests

    • Added validation tests ensuring consistency across different mnemonic derivation approaches.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 26, 2026

Warning

Rate limit exceeded

@QuantumExplorer has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 50 minutes and 42 seconds before requesting another review.

To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d5fb0c7f-efd7-4d60-b392-a04dc0b06e72

📥 Commits

Reviewing files that changed from the base of the PR and between b44bcfb and 29bbe7a.

📒 Files selected for processing (6)
  • packages/swift-sdk/Sources/SwiftDashSDK/Core/Wallet/WalletStorage.swift
  • packages/swift-sdk/Sources/SwiftDashSDK/FFI/KeychainSigner.swift
  • packages/swift-sdk/Sources/SwiftDashSDK/FFI/MnemonicResolverAndPersister.swift
  • packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/Mnemonic.swift
  • packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift
  • packages/swift-sdk/SwiftTests/Tests/SwiftDashSDKTests/PlatformWalletTests.swift
📝 Walkthrough

Walkthrough

The changes refactor mnemonic handling from String-based to UTF-8 byte-based APIs across wallet storage, signing, and derivation layers. New methods in WalletStorage enable efficient keychain presence checks and raw byte retrieval, while MnemonicResolverAndPersister introduces XOR-masking and memory scrubbing to prevent plaintext mnemonics from persisting in memory.

Changes

Cohort / File(s) Summary
Wallet Storage API
packages/swift-sdk/Sources/.../Wallet/WalletStorage.swift
Added retrieveMnemonicUTF8Bytes(for:) for raw byte retrieval and hasMnemonic(for:) for lightweight keychain presence checks. Refactored retrieveMnemonic(for:) to delegate to byte-based method with UTF-8 decoding.
Mnemonic Memory Protection
packages/swift-sdk/Sources/.../KeyWallet/Mnemonic.swift, packages/swift-sdk/Sources/.../FFI/MnemonicResolverAndPersister.swift
Added toSeed(mnemonicUTF8Bytes:passphrase:) overload for byte-based derivation with scrubbing in defer blocks. MnemonicResolver now uses MaskedMnemonicUTF8 to XOR-mask bytes during storage and deobfuscate only during FFI copy, with NUL-byte validation and buffer safety checks.
Usage Updates
packages/swift-sdk/Sources/.../FFI/KeychainSigner.swift, packages/swift-sdk/Sources/.../PlatformWallet/PlatformWalletPersistenceHandler.swift
KeychainSigner changed from retrieveMnemonic (with try?) to hasMnemonic(for:) for platform-address checks. PlatformWalletPersistenceHandler now retrieves mnemonics as Data and uses byte-based toSeed() overload.
Test Coverage
packages/swift-sdk/SwiftTests/Tests/.../PlatformWalletTests.swift
Added deterministic equivalence test comparing String-based and UTF-8 byte-based mnemonic-to-seed derivation paths.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰 Mnemonics now hide in masks of XOR,
Their secrets scrubbed when no longer adore,
Bytes dance where strings once played,
Memory safe, no traces fade!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 77.78% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'obfuscate runtime mnemonic bytes' clearly and specifically summarizes the main change: adding obfuscation of mnemonic bytes at runtime across multiple SDK components.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/mnemonic-obfuscation

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 and usage tips.

@github-actions github-actions Bot added this to the v3.1.0 milestone Apr 26, 2026
Copy link
Copy Markdown
Member Author

@QuantumExplorer QuantumExplorer left a comment

Choose a reason for hiding this comment

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

Self Reviewed

@QuantumExplorer QuantumExplorer marked this pull request as ready for review April 27, 2026 04:22
@thepastaclaw
Copy link
Copy Markdown
Collaborator

thepastaclaw commented Apr 27, 2026

Review Gate

Commit: 29bbe7a9

  • Debounce: 742m ago (need 30m)

  • CI checks: builds passed, 0/2 tests passed

  • CodeRabbit review: comment found

  • Off-peak hours: peak window (5am-11am PT) — currently 10:54 AM PT Monday

  • Run review now (check to override)

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift (1)

755-774: ⚠️ Potential issue | 🟠 Major

mnemonicUTF8Bytes and seed linger in unscrubbable Swift Data along this path.

The PR’s stated goal is to keep plaintext mnemonic bytes off the Swift heap (the new MaskedMnemonicUTF8 and the resolver-callback flow in KeychainSigner.signPlatformAddressOnDemand enforce that for the signing path). On this identity-key derivation path, however, the mnemonic UTF-8 bytes (Line 761) and the derived 64-byte BIP39 seed (Line 770) both sit in plain Data for the rest of the function with no scrub on exit, while the 32-byte privateKey is zeroed (Lines 810/837). Either:

  • Mirror the resolver-based shape used by dash_sdk_sign_with_mnemonic_resolver_and_path and route this derivation through a single Rust entry point that takes the existing MnemonicResolver + path + (out) keychain identifier, so the seed/mnemonic never materialize in a Swift heap object on this path; or
  • At minimum, copy mnemonicUTF8Bytes and seed into [UInt8] buffers and memset_s-scrub them in a defer (same pattern as privateKey), so this path matches the rest of the PR's defense-in-depth.

The first option is the better fit with the swift-sdk guideline that forbids fetching the mnemonic from Keychain and round-tripping it across FFI for an operation Rust can complete end-to-end. As per coding guidelines: "Do not fetch the mnemonic from Keychain, hand it back to Rust, wait for derived bytes, and write those to Keychain—orchestrate the entire pipeline as a single FFI entry point in Rust instead."

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

In
`@packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`
around lines 755 - 774, deriveAndStoreIdentityKey currently fetches
mnemonicUTF8Bytes via WalletStorage().retrieveMnemonicUTF8Bytes and calls
Mnemonic.toSeed which leaves both mnemonicUTF8Bytes and seed as plain Swift Data
on the heap; change this to avoid materializing plaintext: either (preferred)
route the whole derivation into a single Rust FFI entry (e.g., implement a Rust
function analogous to dash_sdk_sign_with_mnemonic_resolver_and_path that accepts
a MnemonicResolver + derivation path + out keychain id and performs
mnemonic→seed→identity-key derivation and storage end-to-end, then call that
from deriveAndStoreIdentityKey instead of retrieving mnemonic bytes in Swift),
or (minimal) immediately copy mnemonicUTF8Bytes and seed into [UInt8] buffers
and add a defer {} that calls memset_s to scrub both buffers before returning
(mirroring the existing privateKey scrub), and stop keeping the original Data
around; reference WalletStorage.retrieveMnemonicUTF8Bytes, Mnemonic.toSeed,
deriveAndStoreIdentityKey, MaskedMnemonicUTF8,
KeychainSigner.signPlatformAddressOnDemand and MnemonicResolver when
implementing the change.
packages/swift-sdk/Sources/SwiftDashSDK/FFI/MnemonicResolverAndPersister.swift (1)

145-194: ⚠️ Potential issue | 🟡 Minor

Masking is undermined by the un-scrubbed mnemonicUTF8Bytes Data held alongside it.

mnemonicUTF8Bytes (Line 147) is a Data whose backing storage is not zeroable, and it stays live for the full duration of this resolve call — i.e., for the entire window during which MaskedMnemonicUTF8 is also alive. Since both copies coexist in memory until function return, the XOR-masked copy provides no defensive benefit on this path: anything that can sweep the heap during resolve can read the plaintext directly from the Data backing.

If the goal is genuinely to keep plaintext off the heap in this resolver, consider having MaskedMnemonicUTF8.init consume an inout [UInt8] (caller-owned, scrub-on-defer) and copying/scrubbing the bytes out of the keychain Data into that [UInt8] buffer immediately at line 147, e.g.:

♻️ Sketch: scrub the keychain `Data` at the boundary
-        let mnemonicUTF8Bytes: Data
+        var mnemonicBytes: [UInt8]
         do {
-            mnemonicUTF8Bytes = try storage.retrieveMnemonicUTF8Bytes(for: walletId)
+            let raw = try storage.retrieveMnemonicUTF8Bytes(for: walletId)
+            mnemonicBytes = [UInt8](raw)
+            // `raw` (Data) is dropped here; only `mnemonicBytes` remains.
         } catch WalletStorageError.mnemonicNotFound { ... }
         ...
+        defer { scrubBytes(&mnemonicBytes) }
         let maskedMnemonic: MaskedMnemonicUTF8
         do {
-            maskedMnemonic = try MaskedMnemonicUTF8(plaintextUTF8Bytes: mnemonicUTF8Bytes)
+            maskedMnemonic = try MaskedMnemonicUTF8(plaintextBytes: &mnemonicBytes)
         } catch { return .other }

(Note that even with this, the Data returned from SecItemCopyMatching is still copied internally by Foundation; reducing the surface to one [UInt8] buffer that you can reliably memset_s is the best Swift can offer here.)

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

In
`@packages/swift-sdk/Sources/SwiftDashSDK/FFI/MnemonicResolverAndPersister.swift`
around lines 145 - 194, The stored plaintext Data (mnemonicUTF8Bytes) coexists
with MaskedMnemonicUTF8, undermining masking; change the flow so you copy the
Data bytes immediately into a caller-owned inout [UInt8] buffer, pass that
buffer into a new consuming initializer on MaskedMnemonicUTF8 (e.g.,
MaskedMnemonicUTF8.init(plaintextBytes: inout [UInt8]) or a consume method),
then securely scrub the original Data-backed storage and the stack buffer on
defer (overwrite with zeros via a secure memset or explicit zeroing) before
returning; update the resolve code to allocate the [UInt8], copy
mnemonicUTF8Bytes into it, remove reliance on mnemonicUTF8Bytes after copying,
and ensure MaskedMnemonicUTF8 consumes/scrubs the buffer so no plaintext Data
lives alongside the masked representation.
🧹 Nitpick comments (2)
packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/Mnemonic.swift (1)

68-70: Legacy toSeed(mnemonic: String, …) still leaves the plaintext mnemonic in an unscrubbable Swift String.

This overload is preserved (and the new test exercises it), so callers that still hand in a String get no obfuscation benefit from the rest of the PR — the bytes will live in the caller's String and in the temporary Data(mnemonic.utf8) until ARC drops them. Consider deprecating this overload (@available(*, deprecated, message: "Prefer toSeed(mnemonicUTF8Bytes:)…")) so callers migrate to the byte-based path, leaving the test the only intentional consumer.

🤖 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 68 - 70, Mark the legacy overload toSeed(mnemonic: String, passphrase:
String? = nil) as deprecated so callers are encouraged to migrate to the
byte-based toSeed(mnemonicUTF8Bytes: Data, passphrase: String?) variant; add an
`@available`(*, deprecated, message: "Prefer toSeed(mnemonicUTF8Bytes:) to avoid
keeping plaintext mnemonic in memory") annotation to the String-based method and
keep its implementation unchanged so tests still pass.
packages/swift-sdk/Sources/SwiftDashSDK/FFI/MnemonicResolverAndPersister.swift (1)

17-61: MaskedMnemonicUTF8.init consumes a Data; consider taking inout [UInt8] to give the caller a scrubbable input buffer.

init(plaintextUTF8Bytes: Data) (Line 21) takes a Data, whose backing buffer the callee cannot scrub. The local plaintext array (Line 22) is correctly zeroed at Line 33/43, but the caller's Data lives on. Accepting an inout [UInt8] would let the caller place the secret bytes into a buffer that can be scrubbed in a defer, removing the residual unscrubbed plaintext from this hot path. See the suggested diff in the resolve(...) comment for the matching call-site shape.

Optional, but it would close the only remaining "plaintext on the Swift heap" window in this resolver.

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

In
`@packages/swift-sdk/Sources/SwiftDashSDK/FFI/MnemonicResolverAndPersister.swift`
around lines 17 - 61, MaskedMnemonicUTF8.init currently accepts a Data
(init(plaintextUTF8Bytes: Data)) which leaves the caller's Data backing buffer
uncleansed; change the initializer to accept an inout [UInt8] (e.g.,
init(plaintextUTF8Bytes: inout [UInt8])) so the caller can supply a scrubbable
buffer, then operate directly on that buffer: generate localMask with
SecRandomCopyBytes into a local array, XOR into maskedBytes, scrub and zero the
input inout buffer with scrubBytes before storing maskedBytes/maskBytes, and
keep deinit and withDeobfuscatedBytes (and scrubBytes calls) unchanged; update
any call site that constructed a Data to instead pass a mutable [UInt8] so no
plaintext remains on the Swift heap.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/swift-sdk/Sources/SwiftDashSDK/Core/Wallet/WalletStorage.swift`:
- Around line 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.

In `@packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/Mnemonic.swift`:
- Around line 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.

---

Outside diff comments:
In
`@packages/swift-sdk/Sources/SwiftDashSDK/FFI/MnemonicResolverAndPersister.swift`:
- Around line 145-194: The stored plaintext Data (mnemonicUTF8Bytes) coexists
with MaskedMnemonicUTF8, undermining masking; change the flow so you copy the
Data bytes immediately into a caller-owned inout [UInt8] buffer, pass that
buffer into a new consuming initializer on MaskedMnemonicUTF8 (e.g.,
MaskedMnemonicUTF8.init(plaintextBytes: inout [UInt8]) or a consume method),
then securely scrub the original Data-backed storage and the stack buffer on
defer (overwrite with zeros via a secure memset or explicit zeroing) before
returning; update the resolve code to allocate the [UInt8], copy
mnemonicUTF8Bytes into it, remove reliance on mnemonicUTF8Bytes after copying,
and ensure MaskedMnemonicUTF8 consumes/scrubs the buffer so no plaintext Data
lives alongside the masked representation.

In
`@packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`:
- Around line 755-774: deriveAndStoreIdentityKey currently fetches
mnemonicUTF8Bytes via WalletStorage().retrieveMnemonicUTF8Bytes and calls
Mnemonic.toSeed which leaves both mnemonicUTF8Bytes and seed as plain Swift Data
on the heap; change this to avoid materializing plaintext: either (preferred)
route the whole derivation into a single Rust FFI entry (e.g., implement a Rust
function analogous to dash_sdk_sign_with_mnemonic_resolver_and_path that accepts
a MnemonicResolver + derivation path + out keychain id and performs
mnemonic→seed→identity-key derivation and storage end-to-end, then call that
from deriveAndStoreIdentityKey instead of retrieving mnemonic bytes in Swift),
or (minimal) immediately copy mnemonicUTF8Bytes and seed into [UInt8] buffers
and add a defer {} that calls memset_s to scrub both buffers before returning
(mirroring the existing privateKey scrub), and stop keeping the original Data
around; reference WalletStorage.retrieveMnemonicUTF8Bytes, Mnemonic.toSeed,
deriveAndStoreIdentityKey, MaskedMnemonicUTF8,
KeychainSigner.signPlatformAddressOnDemand and MnemonicResolver when
implementing the change.

---

Nitpick comments:
In
`@packages/swift-sdk/Sources/SwiftDashSDK/FFI/MnemonicResolverAndPersister.swift`:
- Around line 17-61: MaskedMnemonicUTF8.init currently accepts a Data
(init(plaintextUTF8Bytes: Data)) which leaves the caller's Data backing buffer
uncleansed; change the initializer to accept an inout [UInt8] (e.g.,
init(plaintextUTF8Bytes: inout [UInt8])) so the caller can supply a scrubbable
buffer, then operate directly on that buffer: generate localMask with
SecRandomCopyBytes into a local array, XOR into maskedBytes, scrub and zero the
input inout buffer with scrubBytes before storing maskedBytes/maskBytes, and
keep deinit and withDeobfuscatedBytes (and scrubBytes calls) unchanged; update
any call site that constructed a Data to instead pass a mutable [UInt8] so no
plaintext remains on the Swift heap.

In `@packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/Mnemonic.swift`:
- Around line 68-70: Mark the legacy overload toSeed(mnemonic: String,
passphrase: String? = nil) as deprecated so callers are encouraged to migrate to
the byte-based toSeed(mnemonicUTF8Bytes: Data, passphrase: String?) variant; add
an `@available`(*, deprecated, message: "Prefer toSeed(mnemonicUTF8Bytes:) to
avoid keeping plaintext mnemonic in memory") annotation to the String-based
method and keep its implementation unchanged so tests still pass.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7db9cf56-dfe6-4ab2-b871-6016382c8223

📥 Commits

Reviewing files that changed from the base of the PR and between 461d2f4 and b44bcfb.

📒 Files selected for processing (6)
  • packages/swift-sdk/Sources/SwiftDashSDK/Core/Wallet/WalletStorage.swift
  • packages/swift-sdk/Sources/SwiftDashSDK/FFI/KeychainSigner.swift
  • packages/swift-sdk/Sources/SwiftDashSDK/FFI/MnemonicResolverAndPersister.swift
  • packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/Mnemonic.swift
  • packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift
  • packages/swift-sdk/SwiftTests/Tests/SwiftDashSDKTests/PlatformWalletTests.swift

Comment on lines +125 to +141
/// 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
}
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.

Comment on lines +82 to +87
var mnemonicBytes = [UInt8](mnemonicUTF8Bytes)
guard !mnemonicBytes.contains(0) else {
scrubMnemonicBytes(&mnemonicBytes)
throw KeyWalletError.invalidInput("Mnemonic bytes must not contain NUL")
}
mnemonicBytes.append(0)
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.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 27, 2026

✅ DashSDKFFI.xcframework built for this PR.

SwiftPM (host the zip at a stable URL, then use):

.binaryTarget(
  name: "DashSDKFFI",
  url: "https://your.cdn.example/DashSDKFFI.xcframework.zip",
  checksum: "27428b3467ae300cd12f7399717509aece2c68a3a60546b025dd1c994f49b8a2"
)

Xcode manual integration:

  • Download 'DashSDKFFI.xcframework' artifact from the run link above.
  • Drag it into your app target (Frameworks, Libraries & Embedded Content) and set Embed & Sign.
  • If using the Swift wrapper package, point its binaryTarget to the xcframework location or add the package and place the xcframework at the expected path.

@QuantumExplorer QuantumExplorer force-pushed the feat/mnemonic-obfuscation branch from b44bcfb to 29bbe7a Compare April 27, 2026 05:32
Copy link
Copy Markdown
Contributor

@llbartekll llbartekll left a comment

Choose a reason for hiding this comment

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

Looks good to me, useful incremental security improvement!

@QuantumExplorer QuantumExplorer merged commit 2540e85 into v3.1-dev Apr 27, 2026
34 checks passed
@QuantumExplorer QuantumExplorer deleted the feat/mnemonic-obfuscation branch April 27, 2026 18:02
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.

3 participants