Skip to content
Open
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
41 changes: 39 additions & 2 deletions Bitkit/AppScene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -307,9 +307,45 @@ struct AppScene: View {
}
}

/// Handle orphaned keychain entries from previous app installs.
/// If the installation marker doesn't exist but keychain has data, the app was reinstalled
/// and the keychain data is orphaned (corresponding wallet data was deleted with the app).
private func handleOrphanedKeychain() {
// If marker exists, app was installed before - keychain is valid
if InstallationMarker.exists() {
Logger.debug("Installation marker exists, skipping orphaned keychain check", context: "AppScene")
return
}

// Check if native keychain has data (orphaned from previous install)
let hasNativeKeychain = (try? Keychain.exists(key: .bip39Mnemonic(index: 0))) == true

// Check if RN keychain has data without corresponding RN files (orphaned)
let hasOrphanedRNKeychain = MigrationsService.shared.hasOrphanedRNKeychain()

if hasNativeKeychain || hasOrphanedRNKeychain {
Logger.warn("Orphaned keychain detected, wiping", context: "AppScene")
try? Keychain.wipeEntireKeychain()

if hasOrphanedRNKeychain {
MigrationsService.shared.cleanupRNKeychain()
}
}

// Create marker for this installation
do {
try InstallationMarker.create()
} catch {
Logger.error("Failed to create installation marker: \(error)", context: "AppScene")
}
}

@Sendable
private func setupTask() async {
do {
// Handle orphaned keychain before anything else
handleOrphanedKeychain()

await checkAndPerformRNMigration()
try wallet.setWalletExistsState()

Expand Down Expand Up @@ -340,8 +376,9 @@ struct AppScene: View {
return
}

guard migrations.hasRNWalletData() else {
Logger.info("No RN wallet data found, skipping migration", context: "AppScene")
// Check if RN wallet data exists AND is not orphaned (has corresponding files)
guard migrations.hasRNWalletData(), !migrations.hasOrphanedRNKeychain() else {
Logger.info("No valid RN wallet data found, skipping migration", context: "AppScene")
migrations.markMigrationChecked()
return
}
Expand Down
79 changes: 79 additions & 0 deletions Bitkit/Services/MigrationsService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,26 @@ extension MigrationsService {
return exists
}

/// Returns true if RN keychain has mnemonic but RN app files don't exist.
/// This indicates an orphaned keychain from a deleted RN app install.
func hasOrphanedRNKeychain() -> Bool {
// Check if RN keychain has mnemonic
guard hasRNWalletData() else {
return false
}

// If keychain has mnemonic, check if RN app files exist
// RN app would have created MMKV or LDK files if it was actually used
let hasRNFiles = hasRNMmkvData() || hasRNLdkData()

// Orphaned = has keychain data but no app files
let isOrphaned = !hasRNFiles
if isOrphaned {
Logger.warn("Detected orphaned RN keychain (mnemonic exists but no MMKV/LDK files)", context: "Migration")
}
return isOrphaned
}

func migrateFromReactNative(walletIndex: Int = 0) async throws {
Logger.info("Starting RN migration", context: "Migration")

Expand All @@ -471,6 +491,10 @@ extension MigrationsService {

UserDefaults.standard.set(true, forKey: Self.rnMigrationCompletedKey)
UserDefaults.standard.set(true, forKey: Self.rnMigrationCheckedKey)

// Clean up RN data after successful migration
cleanupAfterMigration()

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

Expand Down Expand Up @@ -556,6 +580,61 @@ extension MigrationsService {
func markMigrationChecked() {
UserDefaults.standard.set(true, forKey: Self.rnMigrationCheckedKey)
}

// MARK: - RN Data Cleanup

/// Delete RN keychain entries (mnemonic, passphrase, PIN)
func cleanupRNKeychain() {
let keysToDelete: [RNKeychainKey] = [
.mnemonic(walletName: rnWalletName),
.passphrase(walletName: rnWalletName),
.pin,
]

for key in keysToDelete {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: key.service,
]
let status = SecItemDelete(query as CFDictionary)
if status == noErr || status == errSecItemNotFound {
Logger.info("Deleted RN keychain key: \(key.service)", context: "Migration")
} else {
Logger.warn("Failed to delete RN keychain key \(key.service): \(status)", context: "Migration")
}
}
}

/// Delete RN MMKV and LDK files from Documents directory
func cleanupRNFiles() {
// Delete MMKV directory
let mmkvDir = rnMmkvPath.deletingLastPathComponent() // ~/Documents/mmkv/
if fileManager.fileExists(atPath: mmkvDir.path) {
do {
try fileManager.removeItem(at: mmkvDir)
Logger.info("Deleted RN MMKV directory", context: "Migration")
} catch {
Logger.warn("Failed to delete RN MMKV directory: \(error)", context: "Migration")
}
}

// Delete LDK directory
if fileManager.fileExists(atPath: rnLdkBasePath.path) {
do {
try fileManager.removeItem(at: rnLdkBasePath)
Logger.info("Deleted RN LDK directory", context: "Migration")
} catch {
Logger.warn("Failed to delete RN LDK directory: \(error)", context: "Migration")
}
}
}

/// Full cleanup after successful migration - removes all RN data
func cleanupAfterMigration() {
cleanupRNKeychain()
cleanupRNFiles()
Logger.info("RN cleanup completed", context: "Migration")
}
}

// MARK: - MMKV Data Migration
Expand Down
3 changes: 3 additions & 0 deletions Bitkit/Utilities/AppReset.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ enum AppReset {
// Wipe keychain
try Keychain.wipeEntireKeychain()

// Delete installation marker so next install can detect orphaned keychain
try? InstallationMarker.delete()

// Wipe user defaults
if let bundleID = Bundle.main.bundleIdentifier {
UserDefaults.standard.removePersistentDomain(forName: bundleID)
Expand Down
47 changes: 47 additions & 0 deletions Bitkit/Utilities/InstallationMarker.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import Foundation

/// Utility to manage an installation marker file that helps detect orphaned keychain entries.
///
/// The marker is placed in the app sandbox Documents directory (NOT the app group container)
/// because this directory is deleted when the app is uninstalled, while the keychain persists.
///
/// By checking if the marker exists at app startup, we can detect if:
/// - The marker exists: App was installed before, keychain is valid
/// - The marker doesn't exist but keychain has data: Orphaned keychain from previous install
///
/// This helps prevent security issues where a reinstalled app might find old keychain data
/// without corresponding wallet data (LDK, CoreDB, UserDefaults).
enum InstallationMarker {
private static let markerFileName = ".bitkit_installed"

/// App sandbox Documents directory (NOT app group) - gets deleted on uninstall
private static var sandboxDocumentsUrl: URL {
FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
}

static var markerPath: URL {
sandboxDocumentsUrl.appendingPathComponent(markerFileName)
}

/// Check if the installation marker exists
static func exists() -> Bool {
FileManager.default.fileExists(atPath: markerPath.path)
}

/// Create the installation marker file
/// Should be called after handling any orphaned keychain detection
static func create() throws {
let data = UUID().uuidString.data(using: .utf8)!
try data.write(to: markerPath)
Logger.info("Installation marker created", context: "InstallationMarker")
}

/// Delete the installation marker file
/// Should be called during app reset/wipe to ensure clean state on next install
static func delete() throws {
if exists() {
try FileManager.default.removeItem(at: markerPath)
Logger.info("Installation marker deleted", context: "InstallationMarker")
}
}
}
18 changes: 17 additions & 1 deletion Bitkit/Utilities/Keychain.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,21 @@ enum KeychainEntryType {
return "security_pin"
}
}

/// Returns the appropriate keychain accessibility level for this key type.
/// - pushNotificationPrivateKey: Uses AfterFirstUnlock because the notification service extension
/// needs to decrypt push payloads even when the device is locked.
/// - All other keys (mnemonic, passphrase, PIN): Use WhenUnlockedThisDeviceOnly for maximum security.
var accessibility: CFString {
switch self {
case .pushNotificationPrivateKey:
// Must be accessible when device is locked for notification extension to decrypt payloads
return kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
case .bip39Mnemonic, .bip39Passphrase, .securityPin:
// Maximum security: only accessible when device is unlocked
return kSecAttrAccessibleWhenUnlockedThisDeviceOnly
}
}
}

class Keychain {
Expand All @@ -28,7 +43,8 @@ class Keychain {
let query =
[
kSecClass as String: kSecClassGenericPassword as String,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock as String,
kSecAttrAccessible as String: key.accessibility as String,
kSecAttrSynchronizable as String: false,
kSecAttrAccount as String: key.storageKey,
kSecValueData as String: data,
kSecAttrAccessGroup as String: Env.keychainGroup,
Expand Down
89 changes: 89 additions & 0 deletions BitkitTests/InstallationMarkerTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
@testable import Bitkit
import XCTest

final class InstallationMarkerTests: XCTestCase {
override func setUp() {
super.setUp()
// Clean up before each test
try? InstallationMarker.delete()
}

override func tearDown() {
// Clean up after each test
try? InstallationMarker.delete()
super.tearDown()
}

func testMarkerDoesNotExistInitially() {
// After cleanup in setUp, marker should not exist
XCTAssertFalse(InstallationMarker.exists())
}

func testCreateMarker() throws {
// Initially should not exist
XCTAssertFalse(InstallationMarker.exists())

// Create the marker
try InstallationMarker.create()

// Now should exist
XCTAssertTrue(InstallationMarker.exists())

// Verify file actually exists at the expected path
XCTAssertTrue(FileManager.default.fileExists(atPath: InstallationMarker.markerPath.path))
}

func testDeleteMarker() throws {
// Create the marker first
try InstallationMarker.create()
XCTAssertTrue(InstallationMarker.exists())

// Delete it
try InstallationMarker.delete()

// Should no longer exist
XCTAssertFalse(InstallationMarker.exists())
XCTAssertFalse(FileManager.default.fileExists(atPath: InstallationMarker.markerPath.path))
}

func testDeleteNonExistentMarkerDoesNotThrow() {
// Ensure marker doesn't exist
XCTAssertFalse(InstallationMarker.exists())

// Deleting non-existent marker should not throw
XCTAssertNoThrow(try InstallationMarker.delete())
}

func testMarkerPathUsesSandboxDocuments() {
// Get the expected sandbox Documents directory
let sandboxDocuments = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]

// Verify marker path is within sandbox Documents, not app group
XCTAssertTrue(InstallationMarker.markerPath.path.hasPrefix(sandboxDocuments.path))

// Verify it's NOT using the app group container
// App group would contain "group.bitkit" in the path
XCTAssertFalse(InstallationMarker.markerPath.path.contains("group.bitkit"))
}

func testCreateMarkerIsIdempotent() throws {
// Create the marker
try InstallationMarker.create()
XCTAssertTrue(InstallationMarker.exists())

// Creating again should overwrite without error
// (the file content is a new UUID each time, but that's fine)
try InstallationMarker.create()
XCTAssertTrue(InstallationMarker.exists())
}

func testMarkerPersistsAcrossChecks() throws {
// Create the marker
try InstallationMarker.create()

// Multiple checks should all return true
XCTAssertTrue(InstallationMarker.exists())
XCTAssertTrue(InstallationMarker.exists())
XCTAssertTrue(InstallationMarker.exists())
}
}
Loading
Loading