Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
d5c6690
feat: create KeychainCrypto
jvsena42 Jan 2, 2026
6cfa94d
chore: create failedToDecrypt error type
jvsena42 Jan 2, 2026
ae29e25
feat: integrate encryption to keychain operations
jvsena42 Jan 2, 2026
b358977
feat: handle orphaned keychain scenario
jvsena42 Jan 2, 2026
098bdec
chore: add keychain key to wipe method
jvsena42 Jan 2, 2026
8fa99f2
test: KeychainCryptoTests
jvsena42 Jan 2, 2026
3810ac5
test: update keychain tests
jvsena42 Jan 2, 2026
c0b61c0
fix: disable icloud data sync
jvsena42 Jan 2, 2026
ab645ca
fix: handle migration
jvsena42 Jan 2, 2026
9578348
fix: check if encryption key exists BEFORE attempting decryption
jvsena42 Jan 2, 2026
e47ce9a
chore: extract app group identifier
jvsena42 Jan 2, 2026
af84f11
chore: convert var to let
jvsena42 Jan 2, 2026
03f937b
fix: revert commit
jvsena42 Jan 2, 2026
6943c77
fix: improve orphaned and migration diferentiation
jvsena42 Jan 2, 2026
1ab0738
chore: remove unused attribute
jvsena42 Jan 2, 2026
b855dfb
Merge branch 'master' into fix/conflicts
jvsena42 Jan 6, 2026
e548bc3
fix: wipe RN keychain after migration
jvsena42 Jan 6, 2026
a6e94a3
fix: don't create another setup if one succeeded
jvsena42 Jan 6, 2026
751a505
fix: prevent backup triggering during migrations
jvsena42 Jan 6, 2026
f0dacd1
Merge branch 'master' into fix/clean-keychain-persistence-uninstall
jvsena42 Jan 7, 2026
3d3a28f
Merge branch 'fix/clean-keychain-persistence-uninstall' of github.com…
jvsena42 Jan 7, 2026
d2d11bf
Merge branch 'fix/pin-not-accepted' into fix/clean-keychain-persisten…
jvsena42 Jan 7, 2026
92920e0
fix: check for orphaned RN data before doing the migration
jvsena42 Jan 7, 2026
3000a78
fix: add conditional compilation to prevent BackupService being compi…
jvsena42 Jan 7, 2026
9a48db5
Merge branch 'master' into fix/clean-keychain-persistence-uninstall
jvsena42 Jan 8, 2026
613a97e
Merge branch 'master' into fix/clean-keychain-persistence-uninstall
jvsena42 Jan 8, 2026
cfba886
Merge branch 'master' into fix/clean-keychain-persistence-uninstall
jvsena42 Jan 9, 2026
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
2 changes: 2 additions & 0 deletions Bitkit.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@
Utilities/Crypto.swift,
Utilities/Errors.swift,
Utilities/Keychain.swift,
Utilities/KeychainCrypto.swift,
Utilities/Logger.swift,
Utilities/StateLocker.swift,
);
Expand All @@ -121,6 +122,7 @@
Utilities/Crypto.swift,
Utilities/Errors.swift,
Utilities/Keychain.swift,
Utilities/KeychainCrypto.swift,
Utilities/Logger.swift,
Utilities/StateLocker.swift,
);
Expand Down
81 changes: 78 additions & 3 deletions Bitkit/AppScene.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import BitkitCore
import Combine
import LDKNode
import SwiftUI
Expand Down Expand Up @@ -287,7 +288,12 @@ struct AppScene: View {
@Sendable
private func setupTask() async {
do {
// CRITICAL: Check for RN migration BEFORE orphaned scenario
// If RN data exists, it's a migration (not orphaned)
await checkAndPerformRNMigration()

// Now check for orphaned keychain (after migration has run)
try await handleOrphanedKeychainScenario()
try wallet.setWalletExistsState()

// Setup TimedSheetManager with all timed sheets
Expand All @@ -311,8 +317,10 @@ struct AppScene: View {
return
}

guard !migrations.hasNativeWalletData() else {
Logger.info("Native wallet data exists, skipping RN migration", context: "AppScene")
// Check if native wallet data exists AND is encrypted
// If data exists but no encryption key, it's plaintext RN data that needs migration
if migrations.hasNativeWalletData() && KeychainCrypto.keyExists() {
Logger.info("Native encrypted wallet data exists, skipping RN migration", context: "AppScene")
migrations.markMigrationChecked()
return
}
Expand All @@ -323,8 +331,21 @@ struct AppScene: View {
return
}

// Check if RN Documents folder exists (LDK or MMKV)
// If keychain exists but Documents is deleted, the RN app was uninstalled
let hasRNDocuments = migrations.hasRNLdkData() || migrations.hasRNMmkvData()
if !hasRNDocuments {
Logger.warn(
"RN keychain found but Documents folder missing - RN app was deleted. Skipping migration and cleaning up orphaned keychain.",
context: "AppScene"
)
migrations.markMigrationChecked()
MigrationsService.shared.wipeRNKeychain()
return
}

await MainActor.run { migrations.isShowingMigrationLoading = true }
Logger.info("RN wallet data found, starting migration...", context: "AppScene")
Logger.info("RN wallet data verified (keychain + Documents exist), starting migration...", context: "AppScene")

do {
try await migrations.migrateFromReactNative()
Expand Down Expand Up @@ -378,6 +399,60 @@ struct AppScene: View {
}
}

private func handleOrphanedKeychainScenario() async throws {
let keychainHasMnemonic = try Keychain.exists(key: .bip39Mnemonic(index: 0))
let encryptionKeyExists = KeychainCrypto.keyExists()

if keychainHasMnemonic, !encryptionKeyExists {
// Could be either:
// 1. Orphaned scenario (encrypted → uninstall → reinstall): keychain has encrypted data, key deleted
// 2. Migration scenario (legacy → encrypted): keychain has plaintext data, key never created
// We differentiate by checking if the data is valid plaintext

do {
guard let data = try Keychain.load(key: .bip39Mnemonic(index: 0)) else {
Logger.warn("Keychain exists check returned true but load returned nil", context: "AppScene")
return
}

// Check if data is valid UTF-8 plaintext (migration scenario)
// Could be: mnemonic (validated via BitkitCore) or passphrase (any valid UTF-8 string)
if let plaintext = String(data: data, encoding: .utf8) {
// Try to validate as BIP39 mnemonic using BitkitCore
let isValidMnemonic = (try? validateMnemonic(mnemonicPhrase: plaintext)) != nil

// Passphrase: any valid UTF-8 string without null bytes
let isValidPassphrase = !plaintext.contains("\0")

if isValidMnemonic || isValidPassphrase {
// This is plaintext data from master - migration scenario
Logger.info("Detected legacy unencrypted keychain - migration will proceed normally", context: "AppScene")
return // Don't wipe, let migration happen
}
}

// Data is encrypted gibberish (not valid plaintext) - orphaned scenario
Logger.warn(
"Detected orphaned keychain state - keychain exists but encryption key missing. Forcing fresh start.",
context: "AppScene"
)

try Keychain.wipeEntireKeychain()

// ALSO wipe RN keychain to prevent migration from recovering orphaned wallet
MigrationsService.shared.wipeRNKeychain()

if let appGroupDefaults = UserDefaults(suiteName: Env.appGroupIdentifier) {
appGroupDefaults.removePersistentDomain(forName: Env.appGroupIdentifier)
}

Logger.info("Orphaned keychain wiped (native + RN). App will show onboarding.", context: "AppScene")
} catch {
Logger.error("Failed to load keychain during orphaned check: \(error).", context: "AppScene")
}
}
}

private func handleNodeLifecycleChange(_ state: NodeLifecycleState) {
if state == .initializing {
walletIsInitializing = true
Expand Down
3 changes: 2 additions & 1 deletion Bitkit/Constants/Env.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import LocalAuthentication

enum Env {
static let appName = "bitkit"
static let appGroupIdentifier = "group.bitkit"

static let isPreview = ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
static let isTestFlight = Bundle.main.appStoreReceiptURL?.lastPathComponent == "sandboxReceipt"
Expand Down Expand Up @@ -129,7 +130,7 @@ enum Env {

static var appStorageUrl: URL {
// App group so files can be shared with extensions
guard let documentsDirectory = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.bitkit") else {
guard let documentsDirectory = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier) else {
fatalError("Could not find documents directory")
}

Expand Down
2 changes: 1 addition & 1 deletion Bitkit/Models/ReceivedTxSheetDetails.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ struct ReceivedTxSheetDetails: Codable {
let type: ReceivedTxType
let sats: UInt64

private static let appGroupUserDefaults = UserDefaults(suiteName: "group.bitkit")
private static let appGroupUserDefaults = UserDefaults(suiteName: Env.appGroupIdentifier)

func save() {
do {
Expand Down
43 changes: 42 additions & 1 deletion Bitkit/Services/MigrationsService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,37 @@ extension MigrationsService {
}
return String(data: data, encoding: .utf8)
}

func wipeRNKeychain() {
// Delete RN mnemonic
deleteFromRNKeychain(key: .mnemonic(walletName: rnWalletName))

// Delete RN passphrase
deleteFromRNKeychain(key: .passphrase(walletName: rnWalletName))

// Delete RN PIN
deleteFromRNKeychain(key: .pin)

Logger.info("Wiped RN keychain", context: "Migration")
}

private func deleteFromRNKeychain(key: RNKeychainKey) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: key.service,
kSecAttrAccount as String: key.service, // RN uses service as account
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny, // Match RN keychain query
]

let status = SecItemDelete(query as CFDictionary)
if status == errSecSuccess {
Logger.debug("Deleted RN keychain key '\(key.service)' from service '\(key.service)'", context: "Migration")
} else if status == errSecItemNotFound {
Logger.debug("RN keychain key '\(key.service)' not found (already deleted)", context: "Migration")
} else {
Logger.warn("Failed to delete RN keychain key '\(key.service)': \(status)", context: "Migration")
}
}
}

// MARK: - RN Migration Detection & Execution
Expand Down Expand Up @@ -451,6 +482,12 @@ extension MigrationsService {
func migrateFromReactNative(walletIndex: Int = 0) async throws {
Logger.info("Starting RN migration", context: "Migration")

// Prevent backups from triggering during migration
#if !UNIT_TESTING
BackupService.shared.setWiping(true)
defer { BackupService.shared.setWiping(false) }
#endif

try migrateMnemonic(walletIndex: walletIndex)
try migratePassphrase(walletIndex: walletIndex)
try migratePin()
Expand All @@ -468,7 +505,11 @@ extension MigrationsService {

UserDefaults.standard.set(true, forKey: Self.rnMigrationCompletedKey)
UserDefaults.standard.set(true, forKey: Self.rnMigrationCheckedKey)
Logger.info("RN migration completed", context: "Migration")

// Clean up RN keychain data after successful migration
wipeRNKeychain()

Logger.info("RN migration completed and cleaned up", context: "Migration")
}

private func migrateMnemonic(walletIndex: Int) throws {
Expand Down
1 change: 1 addition & 0 deletions Bitkit/Services/VssBackupClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ class VssBackupClient {
if let existingSetup = isSetup {
do {
try await existingSetup.value
return // ✅ Don't create another setup if one succeeded!
} catch let error as CancellationError {
isSetup = nil
throw error
Expand Down
10 changes: 9 additions & 1 deletion Bitkit/Utilities/AppReset.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,22 @@ enum AppReset {
// Wipe keychain
try Keychain.wipeEntireKeychain()

// Wipe encryption key
try KeychainCrypto.deleteKey()

// Wipe user defaults
if let bundleID = Bundle.main.bundleIdentifier {
UserDefaults.standard.removePersistentDomain(forName: bundleID)
}

// Prevent RN migration from triggering after wipe
MigrationsService.shared.markMigrationChecked()

// Wipe App Group UserDefaults
if let appGroupDefaults = UserDefaults(suiteName: Env.appGroupIdentifier) {
appGroupDefaults.removePersistentDomain(forName: Env.appGroupIdentifier)
Logger.info("Wiped App Group UserDefaults", context: "AppReset")
}

// Wipe logs
if Env.network == .regtest {
try wipeLogs()
Expand Down
1 change: 1 addition & 0 deletions Bitkit/Utilities/Errors.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ enum KeychainError: Error {
case failedToSaveAlreadyExists
case failedToDelete
case failedToLoad
case failedToDecrypt
case keychainWipeNotAllowed
}

Expand Down
34 changes: 29 additions & 5 deletions Bitkit/Utilities/Keychain.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,15 @@ class Keychain {
class func save(key: KeychainEntryType, data: Data) throws {
Logger.debug("Saving \(key.storageKey)", context: "Keychain")

// Encrypt data before storage
let encryptedData = try KeychainCrypto.encrypt(data)

let query =
[
kSecClass as String: kSecClassGenericPassword as String,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock as String,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly as String,
kSecAttrAccount as String: key.storageKey,
kSecValueData as String: data,
kSecValueData as String: encryptedData,
kSecAttrAccessGroup as String: Env.keychainGroup,
] as [String: Any]

Expand All @@ -49,7 +52,7 @@ class Keychain {
throw KeychainError.failedToSave
}

// Sanity check on save
// Sanity check on save - compare decrypted data with original
guard var storedValue = try load(key: key) else {
Logger.error("Failed to load \(key.storageKey) after saving", context: "Keychain")
throw KeychainError.failedToSave
Expand Down Expand Up @@ -122,8 +125,29 @@ class Keychain {
throw KeychainError.failedToLoad
}

Logger.debug("\(key.storageKey) loaded from keychain")
return dataTypeRef as! Data?
guard let encryptedData = dataTypeRef as? Data else {
throw KeychainError.failedToLoad
}

// Decrypt data after retrieval
// Migration: Check if encryption key exists BEFORE attempting decryption
// (decrypt() will create the key if it doesn't exist, breaking migration detection)
if !KeychainCrypto.keyExists() {
// No encryption key → this is legacy plaintext data from before encryption
Logger.warn("\(key.storageKey) appears to be legacy unencrypted data, returning as-is", context: "Keychain")
return encryptedData // Actually plaintext, will be encrypted on next save
}

// Encryption key exists, attempt decryption
do {
let decryptedData = try KeychainCrypto.decrypt(encryptedData)
Logger.debug("\(key.storageKey) loaded and decrypted from keychain")
return decryptedData
} catch {
// Decryption failed with existing key → truly corrupted/orphaned data
Logger.error("Failed to decrypt \(key.storageKey): \(error)", context: "Keychain")
throw KeychainError.failedToDecrypt
}
}

class func loadString(key: KeychainEntryType) throws -> String? {
Expand Down
Loading
Loading