Skip to content

feat(iOS): keychain mnemonic security - #237

Open
alienx5499 wants to merge 29 commits into
toneloc:mainfrom
alienx5499:feat/ios-keychain-mnemonic-security
Open

feat(iOS): keychain mnemonic security#237
alienx5499 wants to merge 29 commits into
toneloc:mainfrom
alienx5499:feat/ios-keychain-mnemonic-security

Conversation

@alienx5499

@alienx5499 alienx5499 commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Summary

Secures the BIP-39 wallet mnemonic on iOS by migrating it from legacy plaintext disk storage (seed_phrase) to Apple's hardware-secured Keychain.

The Keychain entry is configured with kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly within the group.com.stablechannels.app shared App Group domain. This allows the background NotificationService extension to wake up, boot the LDK Node, and process inbound stability payments when the main app is closed and the device is locked, while preventing cleartext exposure or iCloud syncing.


Problem & Threat Model

  1. Cleartext Seed Exposure: Storing the seed phrase in a plaintext file on disk leaves it exposed to anyone with filesystem access or unencrypted device backups.
  2. Background Wakeups (NSE): The background Notification Service Extension needs to read the seed to boot the LDK Node on push notifications. Gating the Keychain under direct biometric access controls (kSecAccessControl / Face ID prompt) would block silent background receive operations.
  3. Destructive Mutation Risks: Blindly rewriting seeds or wiping state during restore/migration without transactional safety risks permanent funds loss on unexpected process termination (OOM/jetsam).

Key Solutions & Architecture

  1. WalletKeychainService (Encrypted-First & Non-Destructive):

    • Implements MnemonicStorageProtocol with kSecClassGenericPassword and kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly.
    • Idempotent, non-destructive writes: Checks for same-value no-op, uses SecItemUpdate when existing, SecItemAdd when absent, followed by read-after-write verification (never delete-then-add).
    • Dedicated pending slot (seed_phrase_pending) for atomic staged restore transactions.
  2. MnemonicMigrator (Fail-Closed & Encrypted-First):

    • Keychain is strictly authoritative: if Keychain already holds a seed, legacy plaintext is deleted.
    • If both Keychain and plaintext coexist with differing phrases, logs KEYCHAIN_PLAINTEXT_MISMATCH and preserves the Keychain seed without overwriting.
    • Plaintext migration is only permitted on WalletKeychainError.keyNotFound. Any operational error fails closed.
  3. WalletLifecycleManager (Staged Restore Transaction & State Machine):

    • Pre-flight BIP-39 Validation: Validates words and checksum before any storage mutation or node stop.
    • Durable Two-Phase Commit: Stages new seed in pending slot (.pendingValidation), stops node, wipes old database throwing (.oldPersistenceWiped), promotes new seed to active slot, cleans pending slot, and clears phase.
    • Automatic Crash Recovery: If interrupted mid-restore, startup recovers state safely based on the durable phase marker in UserDefaults.
    • Startup Mismatch Matrix: Distinguishes .ready, .newWallet, .seedOnlyMismatch, .dbOnlyMismatch, .seedStorageMismatch, and .storageError.
  4. Safe Seed Recovery UX:

    • Mismatched startup states present the interactive RestoreSeedSheet, requiring the user to supply and validate their backup phrase before any state is modified.

Architecture Diagram

image

Verification & Testing

  • All Unit & Integration Tests Passing: 201 tests passing (xcodebuild test).
  • Isolation: Tests use unique isolated namespaces / mocks, preventing pollution of developer device Keychains.
  • Zero Warnings / SwiftFormat Clean: Codebase conforms strictly to Swift concurrency, SOLID principles, and repository formatting standards.

Related

@alienx5499
alienx5499 force-pushed the feat/ios-keychain-mnemonic-security branch from b0ed7ea to 1734b5d Compare August 12, 2026 14:19
@toneloc

toneloc commented Aug 12, 2026

Copy link
Copy Markdown
Owner

Thanks, can you please add some tests and have you tested this on-device?

@toneloc

toneloc commented Aug 12, 2026

Copy link
Copy Markdown
Owner

Please check the below:

Comment 1 — [P1] Delete-before-rewrite can permanently lose the only seed

storeMnemonic() writes with SecItemDelete immediately followed by SecItemAdd, and start() calls storeMnemonic(words) unconditionally on every existing-wallet startup — including when words was just loaded from the Keychain. After the first migration the plaintext seed_phrase file is gone, so on every subsequent boot the Keychain is the sole copy of the seed, and it is transiently deleted on each launch.

Two ways this loses the seed for good in the gap:

  • The process is killed between the delete and the add (iOS jetsam/OOM, or the NSE's tight time/memory budget) → permanent loss.
  • SecItemAdd fails after the delete succeeded → storeMnemonic throws, but the item is already gone. The read-after-write verification runs after the add, so it can't catch this case.

Consequence: unrecoverable funds loss unless the user still has their written backup. This is a routine code path, not an edge case.

Fix:

  1. Make the write non-destructive: SecItemUpdate when the item exists, SecItemAdd only when it's missing — never delete-then-add.
  2. Additionally, don't rewrite at all when the Keychain already holds the same seed, so the common startup path performs zero destructive writes.
func storeMnemonic(_ mnemonic: String) throws {
    let trimmed = mnemonic.trimmingCharacters(in: .whitespacesAndNewlines)
    guard !trimmed.isEmpty, let data = trimmed.data(using: .utf8) else {
        throw WalletKeychainError.dataConversionFailed
    }

    let base: [String: Any] = [
        kSecClass as String: kSecClassGenericPassword,
        kSecAttrService as String: service,
        kSecAttrAccount as String: account,
        kSecAttrAccessGroup as String: accessGroup
    ]

    // No-op if the stored value already matches — avoids any unnecessary write.
    if let existing = try? loadMnemonic(), existing == trimmed { return }

    if hasMnemonic() {
        let attrs: [String: Any] = [
            kSecValueData as String: data,
            kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
        ]
        let status = SecItemUpdate(base as CFDictionary, attrs as CFDictionary)
        guard status == errSecSuccess else {
            logError("KEYCHAIN_STORE_FAILED", data: ["op": "update", "status": String(status)])
            throw WalletKeychainError.accessDenied(status)
        }
    } else {
        var attrs = base
        attrs[kSecValueData as String] = data
        attrs[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
        let status = SecItemAdd(attrs as CFDictionary, nil)
        guard status == errSecSuccess else {
            logError("KEYCHAIN_STORE_FAILED", data: ["op": "add", "status": String(status)])
            throw WalletKeychainError.accessDenied(status)
        }
    }

    // Read-after-write verification.
    guard (try? loadMnemonic()) == trimmed else {
        logError("KEYCHAIN_VERIFICATION_FAILED", data: [:])
        throw WalletKeychainError.dataConversionFailed
    }
}

Note init()'s migration is less dangerous than start()'s rewrite, because the plaintext file still exists during the gap as a fallback — the uniquely fatal case is start() re-storing after the plaintext has already been deleted.


Comment 2 — [P1] Plaintext overrides an existing Keychain wallet (encrypted-first violated)

init() checks the plaintext seed_phrase file first and, if present, stores it into the Keychain without checking whether the Keychain already holds a different seed and without comparing values, then deletes the plaintext with try? (error ignored). That inverts the encrypted-first rule from #229: plaintext wins over the protected store.

If the two ever coexist and differ — e.g. a prior migration whose plaintext delete silently failed and left the file behind, or a seed_phrase resurrected from a device backup while the Keychain holds the current wallet — the stale/planted plaintext becomes authoritative and clobbers the real Keychain wallet.

Fix: read the Keychain first; migrate the plaintext only when the Keychain is empty; if both exist, compare and treat a mismatch as an error to surface rather than a silent overwrite; and stop ignoring the plaintext-delete failure.

init() {
    let path = Constants.userDataDir.appendingPathComponent("seed_phrase")

    // Encrypted-first: an existing Keychain seed is authoritative.
    if let keychainMnemonic = try? WalletKeychainService.shared.loadMnemonic() {
        savedMnemonic = keychainMnemonic

        // If a plaintext file lingers, reconcile rather than blindly overwrite.
        if let plaintext = try? String(contentsOfFile: path.path, encoding: .utf8) {
            let trimmed = plaintext.trimmingCharacters(in: .whitespacesAndNewlines)
            if !trimmed.isEmpty, trimmed != keychainMnemonic {
                // Two different seeds present — do NOT overwrite. Surface it.
                AuditService.log("KEYCHAIN_PLAINTEXT_MISMATCH", data: [:])
                return
            }
            // Same value (or empty): safe to remove the plaintext, and report if it fails.
            do { try FileManager.default.removeItem(at: path) }
            catch { AuditService.log("KEYCHAIN_PLAINTEXT_DELETE_FAILED",
                                     data: ["error": error.localizedDescription]) }
        }
        return
    }

    // Keychain empty — migrate plaintext if present.
    guard let words = try? String(contentsOfFile: path.path, encoding: .utf8) else { return }
    let trimmed = words.trimmingCharacters(in: .whitespacesAndNewlines)
    guard !trimmed.isEmpty else { return }
    savedMnemonic = trimmed
    do {
        try WalletKeychainService.shared.storeMnemonic(trimmed) // verified write
        do { try FileManager.default.removeItem(at: path) }
        catch { AuditService.log("KEYCHAIN_PLAINTEXT_DELETE_FAILED",
                                 data: ["error": error.localizedDescription]) }
    } catch {
        // Migration failed — keep the plaintext so nothing is lost, and log it.
        AuditService.log("KEYCHAIN_MIGRATION_FAILED", data: ["error": error.localizedDescription])
    }
}

Combined with Comment 1, the theme is: the write path must be idempotent and non-destructive, and the read path must be strictly encrypted-first.

@alienx5499

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review @toneloc

Both points are addressed in the latest update:

  1. Non-destructive Keychain writes: Updated storeMnemonic() to early-return if the stored value matches, and use SecItemUpdate / SecItemAdd instead of SecItemDelete + SecItemAdd.
  2. Encrypted-first initialization: Re-ordered NodeService.init() to check the Keychain first as authoritative, safely reconciling lingering plaintext files without overwriting existing seeds.
  3. Unit Tests: Added WalletKeychainServiceTests covering round-trips, idempotency, updates, whitespace trimming, and deletion.

Tested and verified on my devices.

@toneloc

toneloc commented Aug 13, 2026

Copy link
Copy Markdown
Owner

Comment 1 — [P1] Restore can leave an old seed paired with an empty channel database

NodeService.wipeWalletData() deletes the LDK database files before calling WalletKeychainService.deleteMnemonic(). However, deleteMnemonic() only logs Keychain deletion failures and does not throw or report failure.

During restoreWalletFromMnemonic(), this can produce the following sequence:

  1. The existing LDK channel database is deleted.
  2. Deleting the old Keychain seed fails.
  3. Storing the replacement seed also fails.
  4. Restore exits with an error, leaving the old seed but no channel database.
  5. On the next launch, hasMnemonic() classifies this as an existing wallet and starts the old node identity with empty LDK state.

The restore code already documents that starting a node identity without its existing channel state can cause the counterparty to force-close the channel.

Fix:

  • Make deleteMnemonic() throw or return its OSStatus.
  • Do not delete the LDK database if the seed transition cannot proceed.
  • Make startup explicitly distinguish these states:
    • seed and database both present;
    • seed present but database missing;
    • database present but seed missing;
    • neither present.
  • Never automatically start the node in either mismatched state.
  • Prefer a staged restore transaction: store and verify a pending seed, record a restore marker, wipe the old state, activate the new seed, then clear the marker.

The same state handling is needed for device migration because kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly items do not migrate to a new device.


Comment 2 — [P1] Keychain tests operate on the production wallet item

WalletKeychainServiceTests and MnemonicMigratorTests use WalletKeychainService.shared, including calls to deleteMnemonic() and storeMnemonic().

The “test-specific mnemonic” does not isolate these tests. Keychain items are identified by their service, account, and access group—not by the mnemonic value. The tests therefore access the same item used by the actual wallet:

  • service: com.stablechannels.wallet
  • account: seed_phrase
  • access group: group.com.stablechannels.app

Running the tests on a development device can delete the real wallet seed, replace it with a test mnemonic, or leave a test mnemonic behind after the suite finishes.

Fix:

  • Make WalletKeychainService constructible with configurable service and account values, or inject a Keychain storage protocol.
  • Give every test a unique service/account namespace.
  • Never use WalletKeychainService.shared in tests that store or delete data.
  • Add teardown that removes only the test-specific item.

Comment 3 - [P2] Migration treats every Keychain read error as “not found”

MnemonicMigrator.loadOrMigrateMnemonic() currently uses:

if let keychainMnemonic = try? keychain.loadMnemonic()

This treats access denial, unexpected Security errors, and invalid stored data the same as a missing item. The migrator can then fall back to plaintext even though a Keychain item may exist.

Only WalletKeychainError.keyNotFound should authorize plaintext migration. Other errors should fail closed and leave both stores untouched.

Fix:

do {
    let keychainMnemonic = try keychain.loadMnemonic()
    // Keychain is authoritative; reconcile any plaintext file.
    return keychainMnemonic
} catch WalletKeychainError.keyNotFound {
    // Keychain is genuinely empty; plaintext migration is allowed.
} catch {
    logError?("KEYCHAIN_LOAD_FAILED", [
        "error": error.localizedDescription
    ])
    return nil
}

Add tests confirming that:

  • keyNotFound permits plaintext migration.
  • accessDenied does not trigger plaintext migration.
  • Invalid Keychain data does not silently fall back.
  • A Keychain/plaintext mismatch keeps the Keychain value authoritative.

@alienx5499

Copy link
Copy Markdown
Contributor Author

Thanks @toneloc! Addressed all points: implemented throwing Keychain deletion with a staged two-phase restore transaction (WalletLifecycleManager), isolated tests to unique namespaces/mocks, and made migration fail closed on all Keychain errors except keyNotFound.

@toneloc

toneloc commented Aug 14, 2026

Copy link
Copy Markdown
Owner

Thanks. This is again much improved. It resolves many of the prior concerns.

I am giving this PR a lot of scrutiny because it handles keys. Please assess the below comments for accuracy:

I found two remaining lifecycle issues. 1. should be fixed, 2. is not as important.

  1. Blocker: the restore wipe deletes the pending recovery seed.

    WalletLifecycleManager stores the replacement seed in the pending Keychain slot before wiping, but AppState.wipeAllWalletState() deletes both the active and pending slots.

    Normal restoration succeeds because the seed remains in memory. However, if the process dies after the wipe but before promotion, both durable copies are gone. On restart, recovery sees no pending seed, clears the restore marker, and may proceed as a new wallet.

    The persistence wipe should never delete the pending seed. Only WalletLifecycleManager should remove it after verified promotion. If a restore marker exists but neither the pending nor active seed exists, recovery should fail closed instead of clearing the marker. Please also add an integration test using the real wipe behavior—the current mocks delete only the active seed, so they do not reproduce this failure.

  2. Important: the notification extension bypasses the new mismatch protection.

    The main app blocks seed-without-database and other mismatched states, but the notification extension checks only whether a seed exists. After the main app detects a missing database and releases its lock, a push can start the extension and create a fresh LDK database from that seed, defeating the guard and potentially triggering the force-close scenario it is intended to prevent.

    The extension should run the same lightweight startup preflight: refuse to start while a restore is in progress or when the seed/database state is mismatched, and defer processing through pending_push_payment.

The focused Keychain, migration, and lifecycle tests pass, as does git diff --check.

@alienx5499
alienx5499 force-pushed the feat/ios-keychain-mnemonic-security branch from 2ceec86 to aee937e Compare August 14, 2026 16:20
@alienx5499

Copy link
Copy Markdown
Contributor Author

Thanks @toneloc!
Fixed both: restore wipe now preserves the staged pending seed (recovery fails closed if missing), and the notification extension runs preflight checks to refuse startup on restore or seed/DB mismatch.

toneloc and others added 2 commits August 18, 2026 13:30
… 1/2)

Deleting seed_phrase after Keychain migration made every migrated
wallet one old-build-install away from destruction: earlier builds
treat "no seed files" as a brand-new wallet, wipe the channel database
(destroying the monitors first), and generate a new node identity —
the historic force-close class, triggerable from the TestFlight
previous-builds list.

Staged rollout, step 1: the Keychain is authoritative, and the
plaintext file is deliberately retained and kept in sync as rollback
insurance. NodeService.start() self-heals the file on every successful
start (covers migration, restore, and new-wallet paths in one hook); a
failed insurance write logs SEED_ROLLBACK_COPY_WRITE_FAILED without
blocking the wallet. Plaintext deletion ships in a later release, once
no earlier build remains installable.

Migrator tests updated to pin retention instead of deletion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ED5K6AG9FVeLhrxQbTyKrM
…rable recovery

Four review findings, all in restore error paths:

- The catch around restoreMnemonic released NodeDirLock unconditionally,
  but pre-stop failures (a mistyped seed word failing validation, a
  pending-slot write error) throw while the node is still running —
  dropping the lock with a live node lets the NSE start a second node on
  the same wallet dir, the July multi-writer force-close class. Guard
  the release on !nodeService.isRunning, matching the catch below it.

- The BIP-39 validator built a full LDK node on the main actor,
  freezing the restore UI; it now runs via Task.detached (validator and
  restoreMnemonic became async). Its failure can mean a bad checksum OR
  an environment error, so the message no longer asserts the phrase
  itself is invalid.

- The pending Keychain slot is now the authoritative recovery signal:
  a hard kill can lose the UserDefaults phase marker to an unflushed
  cache, which previously let startup read a mid-restore wallet as
  .newWallet and orphan the staged seed. Markerless recovery promotes a
  pending seed when no active seed exists, clears abandoned staging
  when one does, and fails closed on operational Keychain errors.

- The phase-1 rollback-insurance write now aborts startup on failure
  instead of logging and running uninsured; extracted to
  MnemonicMigrator.syncRollbackCopy for direct failure-path testing.

Six new tests (markerless recovery x3, rollback-copy sync x3); full
signed simulator suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ED5K6AG9FVeLhrxQbTyKrM
toneloc added a commit to alienx5499/stable-channels that referenced this pull request Aug 18, 2026
CODE_SIGNING_ALLOWED=NO strips the test host of its entitlements, so
every test touching the real keychain fails with
errSecMissingEntitlement (-34018) — verified locally: unsigned run
fails 14 keychain/migration tests, normally-signed run passes the full
suite. Simulator builds sign automatically with an ad-hoc signature
(no certificates needed on CI runners), which embeds the
app-group/keychain entitlements the tests require.

This matters before the keychain-migration PR (toneloc#237) rebases onto this
workflow: without it, the workflow would fail that PR's safety-critical
tests for environmental reasons.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ED5K6AG9FVeLhrxQbTyKrM
toneloc and others added 4 commits August 18, 2026 14:30
…39 validation

Three review blockers:

- The rollback-insurance write ran AFTER the Keychain commit, so a
  write failure on first run aborted with the seed already committed
  and no DB — the next launch hit .seedOnlyMismatch and asked a user
  who has never seen a seed phrase to restore from backup words.
  Reordered ahead of the Keychain store: a write failure now commits
  nothing (clean retry), and a Keychain failure leaves plaintext-only,
  the legacy-valid state the migrator already handles. This keeps the
  fail-closed property without the brick.

- Markerless recovery promoted a pending seed whenever no active
  Keychain seed existed — but a legacy wallet's identity lives in
  keys_seed/seed_phrase with its channel database and never had a
  Keychain entry. Promotion now requires zero legacy artifacts
  (keys_seed, seed_phrase, ldk_node_data.sqlite); otherwise it fails
  closed and preserves the evidence, and the legacy wallet keeps
  working. Regression test covers the exact reported scenario.

- LDKNode 0.7.5's generated binding uses try!, so an invalid BIP-39
  word or checksum terminated the process instead of failing
  validation. Added in-app BIP39 validation (wordlist + checksum,
  CryptoKit SHA-256); the 2048-word list is machine-extracted verbatim
  from the rust-bip39 crate and hash-pinned, so a valid seed can never
  be falsely rejected. Guards deriveNodeId (making the restore
  validator and divergence guard safe) and NodeService.start (a
  corrupted stored seed fails closed instead of crash-looping).

Nine new tests, including the exact crash phrase and the misconception
that 12x"zoo" is checksum-valid (it is not; only mock validators
accepted it). Full signed simulator suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ED5K6AG9FVeLhrxQbTyKrM
The BIP-39 validation landed app-target-only, leaving the NSE's two
fromBip39Mnemonic call sites unguarded against LDKNode's try! process
abort — and the NSE is the worst place to crash: unattended, no UI,
and iOS launch-throttles extensions that keep failing, so payment
pushes just quietly stop arriving.

- BIP39.swift + BIP39WordList.swift added to the NotificationService
  target (same shared-source pattern as WalletKeychainService).
- Both NodeStarter entropy sites now validate before the binding
  (keychain and plaintext-fallback sources, distinct log labels), and
  NodeStarterError failures pass through without being mislabeled as
  Keychain access errors.
- fromSeedPath is read-OR-GENERATE: it now refuses to run when
  keys_seed is absent, closing the silent wrong-identity window on the
  unattended path.

SwiftFormat lint clean; full signed simulator suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ED5K6AG9FVeLhrxQbTyKrM
@toneloc

toneloc commented Aug 18, 2026

Copy link
Copy Markdown
Owner

Still working on this and assessing. Very mission-critical code we are touching here @alienx5499

@toneloc

toneloc commented Aug 19, 2026

Copy link
Copy Markdown
Owner

I am still reviewing this. I run this past different AI models, and it keeps finding different bugs! Some of which I have tried to fix here.

So I will keep this open until this approach further matured and/or we get more comfortable with it.

@alienx5499
alienx5499 force-pushed the feat/ios-keychain-mnemonic-security branch 3 times, most recently from dd1801d to 6f70d89 Compare August 25, 2026 07:56
alienx5499 and others added 2 commits August 25, 2026 13:44
…able plaintext seed

Closes the two remaining review findings:

- NodeService.start() no longer creates wallets implicitly. Generation is
  double-gated: an allowCreate flag threaded through the protocol, the
  failover helper, and every call site — true only from the
  lifecycle-confirmed .newWallet branch — plus an unconditional refusal
  when ldk_node_data.sqlite still exists (seedless-with-db is
  .dbOnlyMismatch; wiping the monitors to mint a fresh identity is the
  historic force-close class). Foreground restart, LSP switch, and the
  Esplora failover retry now throw walletStateMismatch instead.

- MnemonicMigrator.loadOrMigrateMnemonic no longer treats a
  present-but-unreadable seed_phrase as absent. That nil was the one
  fail-open in the migrator, and it routed directly into the
  wipe-and-generate path; it now throws plaintextUnreadable, matching the
  fail-closed shape of every other error in the function. Covered by a
  permissions-based test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K7Lyu5AQVGpQN4RAhnv47S
@toneloc

toneloc commented Aug 27, 2026

Copy link
Copy Markdown
Owner

Pushed 703b45b closing the two remaining findings from the reconciled review (see pr-237-final.md in the repo tooling; both reviewers converged on these):

  • Implicit wallet creation is gone, with two independent gates. start() gained allowCreate: Bool (threaded through NodeServiceProtocol, startNodeWithFailover, and both LSPService call sites) — only the lifecycle-confirmed .newWallet branch passes true. Independently of the flag, generation now refuses unconditionally while ldk_node_data.sqlite exists: seedless-with-db is .dbOnlyMismatch, and wiping monitors to mint a fresh identity is the historic force-close class. Foreground restart, LSP switch, and the Esplora failover retry throw the new walletStateMismatch error instead. The second gate also protects any future mis-gated call site.
  • The migrator's one fail-open is closed. A seed_phrase that exists but cannot be read (permissions, I/O error, partial write) no longer reads as "absent" — it throws the new plaintextUnreadable instead of returning the nil that routed into wipe-and-generate. Covered by a permissions-based test (testUnreadablePlaintextThrowsInsteadOfReadingAsAbsent).

Verified on the branch: 267/267 tests pass in the simulator (full scheme incl. the NSE), swiftformat --lint --strict clean.

With these two in, the review verdict is approve. Remaining follow-ups are non-blocking and filed in the review: node-ID cross-check as defense-in-depth, and the phase-2 gate list (backup-restore warning copy must ship BEFORE plaintext deletion). A final merge of main before landing is recommended — the branch is ~10 commits behind and merges cleanly.

@alienx5499
alienx5499 force-pushed the feat/ios-keychain-mnemonic-security branch from 703b45b to 1a66c45 Compare August 27, 2026 16:20
@alienx5499

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review and closing out those final findings @toneloc!

I have rebased the branch on top of latest main with all changes and tests cleanly reconciled.

All 267 tests are passing and SwiftFormat is clean.

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.

iOS: Hardware-Secured Wallet Seed Storage and Background Migration

2 participants