feat(iOS): keychain mnemonic security - #237
Conversation
…orage in WalletKeychainService
…after-write verification and explicit error handling for mnemonic lifecycle management.
b0ed7ea to
1734b5d
Compare
|
Thanks, can you please add some tests and have you tested this on-device? |
|
Please check the below: Comment 1 — [P1] Delete-before-rewrite can permanently lose the only seed
Two ways this loses the seed for good in the gap:
Consequence: unrecoverable funds loss unless the user still has their written backup. This is a routine code path, not an edge case. Fix:
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 Comment 2 — [P1] Plaintext overrides an existing Keychain wallet (encrypted-first violated)
If the two ever coexist and differ — e.g. a prior migration whose plaintext delete silently failed and left the file behind, or a 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. |
…ete and audit logging
|
Thanks for the thorough review @toneloc Both points are addressed in the latest update:
Tested and verified on my devices. |
Comment 1 — [P1] Restore can leave an old seed paired with an empty channel database
During
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:
The same state handling is needed for device migration because Comment 2 — [P1] Keychain tests operate on the production wallet item
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:
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:
Comment 3 - [P2] Migration treats every Keychain read error as “not found”
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 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:
|
…anager to harden security
…anager and harden restore transactions
…nd improve migration resilience.
…39 mnemonic validation in WalletLifecycleManager
…to decouple from AppState logic
…tate initializer instead of setup flow
…g in NodeService, and refine migration log message in NodeStarter
…add a recovery sheet for state mismatches
|
Thanks @toneloc! Addressed all points: implemented throwing Keychain deletion with a staged two-phase restore transaction ( |
|
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.
The focused Keychain, migration, and lifecycle tests pass, as does |
…orrupted recovery, and guard NSE startup
2ceec86 to
aee937e
Compare
|
Thanks @toneloc! |
… 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
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
…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
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
|
Still working on this and assessing. Very mission-critical code we are touching here @alienx5499 |
|
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. |
…eychain seed updates
…re, and add startup state guards
dd1801d to
6f70d89
Compare
…ounds checking for seed restoration
…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
|
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):
Verified on the branch: 267/267 tests pass in the simulator (full scheme incl. the NSE), 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. |
703b45b to
1a66c45
Compare
|
Thanks for the thorough review and closing out those final findings @toneloc! I have rebased the branch on top of latest All 267 tests are passing and SwiftFormat is clean. |
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
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnlywithin thegroup.com.stablechannels.appshared App Group domain. This allows the backgroundNotificationServiceextension 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
kSecAccessControl/ Face ID prompt) would block silent background receive operations.Key Solutions & Architecture
WalletKeychainService(Encrypted-First & Non-Destructive):MnemonicStorageProtocolwithkSecClassGenericPasswordandkSecAttrAccessibleAfterFirstUnlockThisDeviceOnly.SecItemUpdatewhen existing,SecItemAddwhen absent, followed by read-after-write verification (never delete-then-add).seed_phrase_pending) for atomic staged restore transactions.MnemonicMigrator(Fail-Closed & Encrypted-First):KEYCHAIN_PLAINTEXT_MISMATCHand preserves the Keychain seed without overwriting.WalletKeychainError.keyNotFound. Any operational error fails closed.WalletLifecycleManager(Staged Restore Transaction & State Machine):.pendingValidation), stops node, wipes old database throwing (.oldPersistenceWiped), promotes new seed to active slot, cleans pending slot, and clears phase.UserDefaults..ready,.newWallet,.seedOnlyMismatch,.dbOnlyMismatch,.seedStorageMismatch, and.storageError.Safe Seed Recovery UX:
RestoreSeedSheet, requiring the user to supply and validate their backup phrase before any state is modified.Architecture Diagram
Verification & Testing
xcodebuild test).Related