diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 50411b85..5bbc74bd 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -101,6 +101,7 @@ Utilities/Crypto.swift, Utilities/Errors.swift, Utilities/Keychain.swift, + Utilities/KeychainCrypto.swift, Utilities/Logger.swift, Utilities/StateLocker.swift, ); @@ -124,6 +125,7 @@ Utilities/Crypto.swift, Utilities/Errors.swift, Utilities/Keychain.swift, + Utilities/KeychainCrypto.swift, Utilities/Logger.swift, Utilities/StateLocker.swift, ); diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 02344467..e4795f80 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -1,3 +1,4 @@ +import BitkitCore import Combine import LDKNode import SwiftUI @@ -310,7 +311,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 @@ -334,8 +340,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 both native wallet data AND encryption key exist, the wallet is already set up and encrypted - skip migration. + if migrations.hasNativeWalletData() && KeychainCrypto.keyExists() { + Logger.info("Native encrypted wallet data exists, skipping RN migration", context: "AppScene") migrations.markMigrationChecked() return } @@ -346,8 +354,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() @@ -401,6 +422,54 @@ struct AppScene: View { } } + private func handleOrphanedKeychainScenario() async throws { + let keychainHasMnemonic = try Keychain.exists(key: .bip39Mnemonic(index: 0)) + let encryptionKeyExists = KeychainCrypto.keyExists() + + if keychainHasMnemonic, !encryptionKeyExists { + // Check if Documents marker exists (reliably deleted on uninstall) + let hasDocumentsMarker = KeychainCrypto.documentsMarkerExists() + + if !hasDocumentsMarker { + // Fresh install detected - keychain persisted but Documents was cleared + // This is orphaned data from a previous install + Logger.warn( + "Detected orphaned native keychain - keychain exists but Documents marker missing. " + + "App was uninstalled. Forcing fresh start.", + context: "AppScene" + ) + + try Keychain.wipeEntireKeychain() + MigrationsService.shared.wipeRNKeychain() + + if let appGroupDefaults = UserDefaults(suiteName: Env.appGroupIdentifier) { + appGroupDefaults.removePersistentDomain(forName: Env.appGroupIdentifier) + } + + Logger.info("Orphaned native keychain wiped. App will show onboarding.", context: "AppScene") + return + } + + // Documents marker exists but encryption key is missing + // This is a corrupted state - marker and key should exist together + Logger.warn( + "Detected corrupted state - Documents marker exists but encryption key missing. " + + "Forcing fresh start.", + context: "AppScene" + ) + + try Keychain.wipeEntireKeychain() + MigrationsService.shared.wipeRNKeychain() + KeychainCrypto.deleteDocumentsMarker() + + if let appGroupDefaults = UserDefaults(suiteName: Env.appGroupIdentifier) { + appGroupDefaults.removePersistentDomain(forName: Env.appGroupIdentifier) + } + + Logger.info("Corrupted state cleaned. App will show onboarding.", context: "AppScene") + } + } + private func handleNodeLifecycleChange(_ state: NodeLifecycleState) { if state == .initializing { walletIsInitializing = true diff --git a/Bitkit/Constants/Env.swift b/Bitkit/Constants/Env.swift index 4db91915..1ad40731 100644 --- a/Bitkit/Constants/Env.swift +++ b/Bitkit/Constants/Env.swift @@ -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" @@ -99,7 +100,7 @@ enum Env { } /// Returns the lowercase name of the network (e.g., "bitcoin", "testnet", "signet", "regtest") - private static func networkName(_ network: LDKNode.Network) -> String { + static func networkName(_ network: LDKNode.Network = Env.network) -> String { switch network { case .bitcoin: "bitcoin" case .testnet: "testnet" @@ -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") } diff --git a/Bitkit/Models/ReceivedTxSheetDetails.swift b/Bitkit/Models/ReceivedTxSheetDetails.swift index 069f5fa8..0d7cc427 100644 --- a/Bitkit/Models/ReceivedTxSheetDetails.swift +++ b/Bitkit/Models/ReceivedTxSheetDetails.swift @@ -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 { diff --git a/Bitkit/Services/MigrationsService.swift b/Bitkit/Services/MigrationsService.swift index 51d02d8e..a5dca352 100644 --- a/Bitkit/Services/MigrationsService.swift +++ b/Bitkit/Services/MigrationsService.swift @@ -407,6 +407,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 @@ -454,6 +485,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() @@ -471,7 +508,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 { diff --git a/Bitkit/Services/RNBackupClient.swift b/Bitkit/Services/RNBackupClient.swift index b22fb03d..6bd4ecd4 100644 --- a/Bitkit/Services/RNBackupClient.swift +++ b/Bitkit/Services/RNBackupClient.swift @@ -79,12 +79,7 @@ class RNBackupClient { } private func networkString() -> String { - switch Env.network { - case .bitcoin: "bitcoin" - case .testnet: "testnet" - case .regtest: "regtest" - case .signet: "signet" - } + Env.networkName() } private func deriveSeed(mnemonic: String, passphrase: String?) throws -> Data { diff --git a/Bitkit/Services/VssBackupClient.swift b/Bitkit/Services/VssBackupClient.swift index cfb4d247..46771d06 100644 --- a/Bitkit/Services/VssBackupClient.swift +++ b/Bitkit/Services/VssBackupClient.swift @@ -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 diff --git a/Bitkit/Utilities/AppReset.swift b/Bitkit/Utilities/AppReset.swift index d76fe6ed..276eea61 100644 --- a/Bitkit/Utilities/AppReset.swift +++ b/Bitkit/Utilities/AppReset.swift @@ -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() diff --git a/Bitkit/Utilities/Errors.swift b/Bitkit/Utilities/Errors.swift index 3838cc6a..c94f6e00 100644 --- a/Bitkit/Utilities/Errors.swift +++ b/Bitkit/Utilities/Errors.swift @@ -21,6 +21,7 @@ enum KeychainError: Error { case failedToSaveAlreadyExists case failedToDelete case failedToLoad + case failedToDecrypt case keychainWipeNotAllowed } diff --git a/Bitkit/Utilities/Keychain.swift b/Bitkit/Utilities/Keychain.swift index 6fd90cb6..87db0d4b 100644 --- a/Bitkit/Utilities/Keychain.swift +++ b/Bitkit/Utilities/Keychain.swift @@ -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] @@ -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 @@ -122,8 +125,29 @@ class Keychain { throw KeychainError.failedToLoad } - Logger.debug("\(key.storageKey) loaded from keychain") - return dataTypeRef as! Data? + guard let keychainData = 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 keychainData // Actually plaintext, will be encrypted on next save + } + + // Encryption key exists, attempt decryption + do { + let decryptedData = try KeychainCrypto.decrypt(keychainData) + 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? { diff --git a/Bitkit/Utilities/KeychainCrypto.swift b/Bitkit/Utilities/KeychainCrypto.swift new file mode 100644 index 00000000..106f00fd --- /dev/null +++ b/Bitkit/Utilities/KeychainCrypto.swift @@ -0,0 +1,141 @@ +import CryptoKit +import Foundation + +class KeychainCrypto { + private static var cachedKey: SymmetricKey? + private static let keyFileName = ".keychain_encryption_key" + + // Network-specific key path (matches existing patterns) + private static var keyFilePath: URL { + Env.appStorageUrl + .appendingPathComponent(Env.networkName()) + .appendingPathComponent(keyFileName) + } + + // Get or create encryption key + static func getOrCreateKey() throws -> SymmetricKey { + // Return cached key if available + if let cached = cachedKey { + return cached + } + + // Try to load existing key + if FileManager.default.fileExists(atPath: keyFilePath.path) { + let keyData = try Data(contentsOf: keyFilePath) + let key = SymmetricKey(data: keyData) + cachedKey = key + Logger.debug("Loaded encryption key from storage", context: "KeychainCrypto") + return key + } + + // Create new key + let newKey = SymmetricKey(size: .bits256) + try saveKey(newKey) + try createDocumentsMarker() + cachedKey = newKey + Logger.info("Created new encryption key", context: "KeychainCrypto") + return newKey + } + + private static func saveKey(_ key: SymmetricKey) throws { + // Ensure directory exists + let directory = keyFilePath.deletingLastPathComponent() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + + // Save key data with file protection + let keyData = key.withUnsafeBytes { Data($0) } + try keyData.write(to: keyFilePath, options: .completeFileProtection) + + Logger.debug("Saved encryption key to \(keyFilePath.path)", context: "KeychainCrypto") + } + + // Check if key exists + static func keyExists() -> Bool { + return FileManager.default.fileExists(atPath: keyFilePath.path) + } + + // Delete key (used during wipe) + static func deleteKey() throws { + if FileManager.default.fileExists(atPath: keyFilePath.path) { + try FileManager.default.removeItem(at: keyFilePath) + cachedKey = nil + Logger.info("Deleted encryption key", context: "KeychainCrypto") + } + deleteDocumentsMarker() + } + + // Encrypt data before keychain storage + static func encrypt(_ data: Data) throws -> Data { + let key = try getOrCreateKey() + let sealedBox = try AES.GCM.seal(data, using: key) + + // Combine nonce + ciphertext + tag into single Data blob + var combined = Data() + combined.append(sealedBox.nonce.withUnsafeBytes { Data($0) }) + combined.append(sealedBox.ciphertext) + combined.append(sealedBox.tag) + + Logger.debug("Encrypted data (\(data.count) bytes → \(combined.count) bytes)", context: "KeychainCrypto") + return combined + } + + // Decrypt data after keychain retrieval + static func decrypt(_ encryptedData: Data) throws -> Data { + let key = try getOrCreateKey() + + // Extract components (nonce=12 bytes, tag=16 bytes, rest=ciphertext) + guard encryptedData.count >= 28 else { // 12 + 16 minimum + Logger.error("Invalid encrypted data: too short (\(encryptedData.count) bytes)", context: "KeychainCrypto") + throw KeychainCryptoError.invalidEncryptedData + } + + let nonceData = encryptedData.prefix(12) + let tagData = encryptedData.suffix(16) + let ciphertextData = encryptedData.dropFirst(12).dropLast(16) + + do { + let nonce = try AES.GCM.Nonce(data: nonceData) + let sealedBox = try AES.GCM.SealedBox(nonce: nonce, ciphertext: ciphertextData, tag: tagData) + let decryptedData = try AES.GCM.open(sealedBox, using: key) + + Logger.debug("Decrypted data (\(encryptedData.count) bytes → \(decryptedData.count) bytes)", context: "KeychainCrypto") + return decryptedData + } catch { + Logger.error("Decryption failed: \(error.localizedDescription)", context: "KeychainCrypto") + throw KeychainCryptoError.decryptionFailed + } + } + + enum KeychainCryptoError: Error { + case invalidEncryptedData + case keyNotFound + case decryptionFailed + } + + // MARK: - Documents Marker (for orphaned keychain detection) + + private static var documentsMarkerPath: URL { + let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask) + return paths[0].appendingPathComponent(".wallet_setup_marker") + } + + /// Check if the Documents marker exists (indicates valid wallet setup on this install) + static func documentsMarkerExists() -> Bool { + return FileManager.default.fileExists(atPath: documentsMarkerPath.path) + } + + /// Create the Documents marker (called when encryption key is created) + private static func createDocumentsMarker() throws { + let markerData = Data("bitkit_wallet_v1".utf8) + try markerData.write(to: documentsMarkerPath, options: .atomic) + Logger.debug("Created wallet setup marker in Documents", context: "KeychainCrypto") + } + + /// Delete the Documents marker (called during wallet wipe) + static func deleteDocumentsMarker() { + if FileManager.default.fileExists(atPath: documentsMarkerPath.path) { + try? FileManager.default.removeItem(at: documentsMarkerPath) + Logger.debug("Deleted wallet setup marker", context: "KeychainCrypto") + } + } +} diff --git a/BitkitTests/KeychainCryptoTests.swift b/BitkitTests/KeychainCryptoTests.swift new file mode 100644 index 00000000..8b22b233 --- /dev/null +++ b/BitkitTests/KeychainCryptoTests.swift @@ -0,0 +1,335 @@ +@testable import Bitkit +import CryptoKit +import XCTest + +final class KeychainCryptoTests: XCTestCase { + override func setUp() { + super.setUp() + // Clean up any existing encryption key before each test + try? KeychainCrypto.deleteKey() + } + + override func tearDown() { + // Clean up after each test + try? KeychainCrypto.deleteKey() + super.tearDown() + } + + // MARK: - Key Generation Tests + + func testKeyGenerationCreates256BitKey() throws { + // When: Creating a new encryption key + let key = try KeychainCrypto.getOrCreateKey() + + // Then: Key should be 256 bits (32 bytes) + key.withUnsafeBytes { bytes in + XCTAssertEqual(bytes.count, 32, "Key should be 256 bits (32 bytes)") + } + } + + func testKeyPersistenceToFile() throws { + // Given: No key exists initially + XCTAssertFalse(KeychainCrypto.keyExists()) + + // When: Creating a key + _ = try KeychainCrypto.getOrCreateKey() + + // Then: Key file should exist + XCTAssertTrue(KeychainCrypto.keyExists()) + } + + func testKeyCreationAfterDeletion() throws { + // Given: A key has been created + let originalKey = try KeychainCrypto.getOrCreateKey() + var originalData = Data() + originalKey.withUnsafeBytes { originalData = Data($0) } + + // When: Deleting the key and creating a new one + try KeychainCrypto.deleteKey() + let newKey = try KeychainCrypto.getOrCreateKey() + var newData = Data() + newKey.withUnsafeBytes { newData = Data($0) } + + // Then: A new 256-bit key should be created (different from original) + XCTAssertEqual(newData.count, 32, "New key should be 256 bits") + XCTAssertNotEqual(newData, originalData, "New key should be different from deleted key") + } + + func testKeyCaching() throws { + // Given: A key has been created + let firstKey = try KeychainCrypto.getOrCreateKey() + + // When: Calling getOrCreateKey again (should use cache) + let cachedKey = try KeychainCrypto.getOrCreateKey() + + // Then: Should return the same key instance (from cache) + var firstData = Data() + var cachedData = Data() + + firstKey.withUnsafeBytes { firstData = Data($0) } + cachedKey.withUnsafeBytes { cachedData = Data($0) } + + XCTAssertEqual(firstData, cachedData, "Cached key should match first key") + } + + // MARK: - Encryption Tests + + func testEncryptionProducesDifferentOutputForSameInput() throws { + // Given: Same plaintext data + let plaintext = "test data".data(using: .utf8)! + + // When: Encrypting the same data twice + let encrypted1 = try KeychainCrypto.encrypt(plaintext) + let encrypted2 = try KeychainCrypto.encrypt(plaintext) + + // Then: Encrypted outputs should differ (due to random nonce) + XCTAssertNotEqual(encrypted1, encrypted2, "Encryption should produce different output due to random nonce") + } + + func testEncryptionDecryptionRoundTrip() throws { + // Given: Original plaintext data + let originalData = "Hello, World! This is a test of encryption.".data(using: .utf8)! + + // When: Encrypting and then decrypting + let encrypted = try KeychainCrypto.encrypt(originalData) + let decrypted = try KeychainCrypto.decrypt(encrypted) + + // Then: Decrypted data should match original + XCTAssertEqual(decrypted, originalData, "Decrypted data should match original") + } + + func testEncryptionWithVariousDataSizes() throws { + // Test with different data sizes + let testCases: [String] = [ + "", // Empty + "a", // Single character + "Short text", // Short + String(repeating: "Long text ", count: 100), // Long + String(repeating: "Very long ", count: 1000), // Very long + ] + + for testString in testCases { + // Given: Test data + let original = testString.data(using: .utf8)! + + // When: Encrypting and decrypting + let encrypted = try KeychainCrypto.encrypt(original) + let decrypted = try KeychainCrypto.decrypt(encrypted) + + // Then: Should match + XCTAssertEqual( + decrypted, + original, + "Round-trip should work for data of size \(original.count)" + ) + } + } + + // MARK: - Decryption Failure Tests + + func testDecryptWithCorruptedDataFails() throws { + // Given: Properly encrypted data + let plaintext = "test data".data(using: .utf8)! + var encrypted = try KeychainCrypto.encrypt(plaintext) + + // When: Corrupting the encrypted data + encrypted[encrypted.count - 1] ^= 0xFF // Flip bits in last byte + + // Then: Decryption should fail + XCTAssertThrowsError(try KeychainCrypto.decrypt(encrypted)) { error in + XCTAssertTrue( + error is KeychainCrypto.KeychainCryptoError, + "Should throw KeychainCryptoError" + ) + } + } + + func testDecryptWithTooShortDataFails() throws { + // Given: Data that's too short to be valid encrypted data (< 28 bytes) + let tooShortData = Data(repeating: 0, count: 20) + + // Then: Should throw invalidEncryptedData error + XCTAssertThrowsError(try KeychainCrypto.decrypt(tooShortData)) { error in + guard let cryptoError = error as? KeychainCrypto.KeychainCryptoError else { + XCTFail("Should throw KeychainCryptoError") + return + } + XCTAssertEqual(cryptoError, .invalidEncryptedData) + } + } + + func testDecryptWithInvalidNonceFails() throws { + // Given: Data with invalid nonce (but correct length) + var invalidData = Data(repeating: 0xFF, count: 50) + // Make last 16 bytes valid-ish (for tag) + for i in 34 ..< 50 { + invalidData[i] = UInt8.random(in: 0 ... 255) + } + + // Then: Should throw decryption error + XCTAssertThrowsError(try KeychainCrypto.decrypt(invalidData)) + } + + // MARK: - Key Management Tests + + func testKeyExistsReturnsFalseInitially() { + // Given: Clean state (setUp deletes any existing key) + // Then: Key should not exist + XCTAssertFalse(KeychainCrypto.keyExists()) + } + + func testKeyExistsReturnsTrueAfterCreation() throws { + // Given: No key initially + XCTAssertFalse(KeychainCrypto.keyExists()) + + // When: Creating a key + _ = try KeychainCrypto.getOrCreateKey() + + // Then: Key should exist + XCTAssertTrue(KeychainCrypto.keyExists()) + } + + func testDeleteKeyRemovesFile() throws { + // Given: A key exists + _ = try KeychainCrypto.getOrCreateKey() + XCTAssertTrue(KeychainCrypto.keyExists()) + + // When: Deleting the key + try KeychainCrypto.deleteKey() + + // Then: Key should no longer exist + XCTAssertFalse(KeychainCrypto.keyExists()) + } + + func testDeleteKeyClearsCache() throws { + // Given: A key exists and is cached + let originalKey = try KeychainCrypto.getOrCreateKey() + var originalData = Data() + originalKey.withUnsafeBytes { originalData = Data($0) } + + // When: Deleting the key and creating a new one + try KeychainCrypto.deleteKey() + let newKey = try KeychainCrypto.getOrCreateKey() + var newData = Data() + newKey.withUnsafeBytes { newData = Data($0) } + + // Then: New key should be different (cache was cleared) + XCTAssertNotEqual(originalData, newData, "New key should be different from deleted key") + } + + func testDeleteNonexistentKeyDoesNotThrow() throws { + // Given: No key exists + XCTAssertFalse(KeychainCrypto.keyExists()) + + // When/Then: Deleting should not throw + XCTAssertNoThrow(try KeychainCrypto.deleteKey()) + } + + // MARK: - Encrypted Data Format Tests + + func testEncryptedDataContainsNonceCiphertextAndTag() throws { + // Given: Original data + let plaintext = "test".data(using: .utf8)! + + // When: Encrypting + let encrypted = try KeychainCrypto.encrypt(plaintext) + + // Then: Encrypted data should be at least 28 bytes (12 nonce + 16 tag) + XCTAssertGreaterThanOrEqual( + encrypted.count, + 28, + "Encrypted data should contain at least nonce (12) + tag (16)" + ) + + // And: Should contain the plaintext length + overhead + let expectedMinSize = 12 + plaintext.count + 16 + XCTAssertEqual(encrypted.count, expectedMinSize) + } + + // MARK: - Integration Tests + + func testMultipleEncryptDecryptCycles() throws { + // Given: Multiple pieces of data + let testData = [ + "First test data", + "Second test data", + "Third test data with more content", + ] + + // When: Encrypting and decrypting each + for testString in testData { + let original = testString.data(using: .utf8)! + let encrypted = try KeychainCrypto.encrypt(original) + let decrypted = try KeychainCrypto.decrypt(encrypted) + + // Then: Each should decrypt correctly + XCTAssertEqual(decrypted, original) + } + } + + func testEncryptionWithBinaryData() throws { + // Given: Binary data (not UTF-8 text) + var binaryData = Data() + for i in 0 ..< 256 { + binaryData.append(UInt8(i)) + } + + // When: Encrypting and decrypting + let encrypted = try KeychainCrypto.encrypt(binaryData) + let decrypted = try KeychainCrypto.decrypt(encrypted) + + // Then: Should preserve binary data exactly + XCTAssertEqual(decrypted, binaryData) + } + + // MARK: - Security Tests + + func testEncryptedDataDoesNotContainPlaintext() throws { + // Given: Plaintext with distinctive pattern + let plaintext = "DISTINCTIVE_PATTERN_12345".data(using: .utf8)! + + // When: Encrypting + let encrypted = try KeychainCrypto.encrypt(plaintext) + + // Then: Encrypted data should not contain the plaintext pattern + let encryptedString = String(data: encrypted, encoding: .utf8) ?? "" + XCTAssertFalse( + encryptedString.contains("DISTINCTIVE_PATTERN"), + "Encrypted data should not contain plaintext" + ) + } + + // MARK: - Documents Marker Tests + + func testDocumentsMarkerCreatedWithKey() throws { + // Given: No key exists + XCTAssertFalse(KeychainCrypto.keyExists()) + XCTAssertFalse(KeychainCrypto.documentsMarkerExists()) + + // When: Creating a key + _ = try KeychainCrypto.getOrCreateKey() + + // Then: Both key and marker should exist + XCTAssertTrue(KeychainCrypto.keyExists()) + XCTAssertTrue(KeychainCrypto.documentsMarkerExists()) + } + + func testDocumentsMarkerDeletedWithKey() throws { + // Given: Key and marker exist + _ = try KeychainCrypto.getOrCreateKey() + XCTAssertTrue(KeychainCrypto.documentsMarkerExists()) + + // When: Deleting the key + try KeychainCrypto.deleteKey() + + // Then: Both should be deleted + XCTAssertFalse(KeychainCrypto.keyExists()) + XCTAssertFalse(KeychainCrypto.documentsMarkerExists()) + } + + func testDocumentsMarkerExistsReturnsFalseInitially() { + // Given: Clean state (setUp deletes any existing key and marker) + // Then: Marker should not exist + XCTAssertFalse(KeychainCrypto.documentsMarkerExists()) + } +} diff --git a/BitkitTests/KeychainTests.swift b/BitkitTests/KeychainTests.swift index 86bdaede..1f4d4153 100644 --- a/BitkitTests/KeychainTests.swift +++ b/BitkitTests/KeychainTests.swift @@ -1,12 +1,14 @@ +@testable import Bitkit import XCTest final class KeychainTests: XCTestCase { override func setUpWithError() throws { try Keychain.wipeEntireKeychain() + try? KeychainCrypto.deleteKey() // Clean encryption key before each test } override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. + try? KeychainCrypto.deleteKey() // Clean encryption key after each test } func testKeychain() throws { @@ -38,7 +40,9 @@ final class KeychainTests: XCTestCase { // Check all keys are saved correctly let listedKeys = Keychain.getAllKeyChainStorageKeys() - XCTAssertEqual(listedKeys.count, 12) + // Note: getAllKeyChainStorageKeys() returns ALL keychain items (all apps), + // so we check for at least our 12 items, not exactly 12 + XCTAssertGreaterThanOrEqual(listedKeys.count, 12, "Should have at least our 12 items") for i in 0 ... 5 { XCTAssertTrue(listedKeys.contains("bip39_mnemonic_\(i)")) XCTAssertTrue(listedKeys.contains("bip39_passphrase_\(i)")) @@ -53,8 +57,258 @@ final class KeychainTests: XCTestCase { // Wipe try Keychain.wipeEntireKeychain() - // Check all keys are gone - let listedKeysAfterWipe = Keychain.getAllKeyChainStorageKeys() - XCTAssertEqual(listedKeysAfterWipe.count, 0) + // Check our keys are gone (verify specific keys, not count) + for i in 0 ... 5 { + XCTAssertNil(try Keychain.loadString(key: .bip39Mnemonic(index: i))) + XCTAssertNil(try Keychain.loadString(key: .bip39Passphrase(index: i))) + } + } + + // MARK: - Encryption Integration Tests + + func testKeychainDataIsEncrypted() throws { + // Given: A test mnemonic + let testMnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + + // When: Saving to keychain + try Keychain.saveString(key: .bip39Mnemonic(index: 0), str: testMnemonic) + + // Then: Encryption key should have been created + XCTAssertTrue(KeychainCrypto.keyExists(), "Encryption key should be created when saving to keychain") + + // And: Data should be retrievable and match original + let retrieved = try Keychain.loadString(key: .bip39Mnemonic(index: 0)) + XCTAssertEqual(retrieved, testMnemonic, "Retrieved data should match original") + } + + func testKeychainWithoutEncryptionKeyReturnsEncryptedData() throws { + // Given: A saved mnemonic with encryption + let testMnemonic = "test mnemonic with encryption" + try Keychain.saveString(key: .bip39Mnemonic(index: 0), str: testMnemonic) + + // Get the encrypted data for comparison + var encryptedData: Data? + var dataTypeRef: AnyObject? + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: "bip39_mnemonic_0", + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecAttrAccessGroup as String: Env.keychainGroup, + ] + SecItemCopyMatching(query as CFDictionary, &dataTypeRef) + encryptedData = dataTypeRef as? Data + + // When: Deleting the encryption key (simulating orphaned scenario) + try KeychainCrypto.deleteKey() + + // Then: Loading returns the encrypted data as-is (migration path) + // Note: This is encrypted garbage, but AppScene.handleOrphanedKeychainScenario() + // will detect this scenario and wipe the keychain before the app starts + let loaded = try Keychain.load(key: .bip39Mnemonic(index: 0)) + XCTAssertEqual(loaded, encryptedData, "Should return encrypted data as-is when no key exists") + + // And: The loaded data should NOT equal the original plaintext + let loadedString = String(data: loaded!, encoding: .utf8) + XCTAssertNotEqual(loadedString, testMnemonic, "Returned data should be encrypted, not plaintext") + } + + func testMultipleKeychainItemsUseSameEncryptionKey() throws { + // Given: Multiple test values + let testMnemonic = "test mnemonic" + let testPassphrase = "test passphrase" + let testPin = "123456" + + // When: Saving multiple items + try Keychain.saveString(key: .bip39Mnemonic(index: 0), str: testMnemonic) + try Keychain.saveString(key: .bip39Passphrase(index: 0), str: testPassphrase) + try Keychain.saveString(key: .securityPin, str: testPin) + + // Then: All should be retrievable + XCTAssertEqual(try Keychain.loadString(key: .bip39Mnemonic(index: 0)), testMnemonic) + XCTAssertEqual(try Keychain.loadString(key: .bip39Passphrase(index: 0)), testPassphrase) + XCTAssertEqual(try Keychain.loadString(key: .securityPin), testPin) + + // And: Only one encryption key file should exist + XCTAssertTrue(KeychainCrypto.keyExists()) + } + + func testKeychainEncryptionWithBinaryData() throws { + // Given: Binary data (push notification private key) + var binaryData = Data() + for i in 0 ..< 32 { + binaryData.append(UInt8(i)) + } + + // When: Saving binary data + try Keychain.save(key: .pushNotificationPrivateKey, data: binaryData) + + // Then: Should be retrievable and match exactly + let retrieved = try Keychain.load(key: .pushNotificationPrivateKey) + XCTAssertEqual(retrieved, binaryData, "Binary data should be preserved exactly") + } + + func testKeychainWipeDoesNotDeleteEncryptionKey() throws { + // Given: Saved keychain items + try Keychain.saveString(key: .bip39Mnemonic(index: 0), str: "test") + XCTAssertTrue(KeychainCrypto.keyExists()) + + // When: Wiping keychain + try Keychain.wipeEntireKeychain() + + // Then: Our keychain item should be gone + XCTAssertNil(try Keychain.loadString(key: .bip39Mnemonic(index: 0))) + + // But: Encryption key is NOT deleted by wipeEntireKeychain() + // This is intentional - only AppReset.wipe() deletes the encryption key + // The key will be reused if new items are saved + XCTAssertTrue(KeychainCrypto.keyExists(), "Encryption key should persist after keychain wipe") + } + + func testEncryptionPreservesUnicodeCharacters() throws { + // Given: Mnemonic with unicode characters + let unicodeMnemonic = "test émoji 🔑 中文 العربية" + + // When: Saving and loading + try Keychain.saveString(key: .bip39Mnemonic(index: 0), str: unicodeMnemonic) + let retrieved = try Keychain.loadString(key: .bip39Mnemonic(index: 0)) + + // Then: Unicode should be preserved + XCTAssertEqual(retrieved, unicodeMnemonic) + } + + func testEncryptionWithEmptyString() throws { + // Given: Empty passphrase + let emptyPassphrase = "" + + // When: Saving and loading + try Keychain.saveString(key: .bip39Passphrase(index: 0), str: emptyPassphrase) + let retrieved = try Keychain.loadString(key: .bip39Passphrase(index: 0)) + + // Then: Empty string should be preserved + XCTAssertEqual(retrieved, emptyPassphrase) + } + + func testEncryptionKeyPersistsAcrossMultipleSaves() throws { + // Given: First save creates encryption key + try Keychain.saveString(key: .bip39Mnemonic(index: 0), str: "first") + let firstKeyExists = KeychainCrypto.keyExists() + XCTAssertTrue(firstKeyExists) + + // When: Deleting first item and saving another + try Keychain.delete(key: .bip39Mnemonic(index: 0)) + try Keychain.saveString(key: .bip39Mnemonic(index: 1), str: "second") + + // Then: Same encryption key should be reused + XCTAssertTrue(KeychainCrypto.keyExists()) + + // And: Both old and new items work (new one is retrievable) + XCTAssertNil(try Keychain.loadString(key: .bip39Mnemonic(index: 0))) // Deleted + XCTAssertEqual(try Keychain.loadString(key: .bip39Mnemonic(index: 1)), "second") + } + + // MARK: - Migration Tests + + func testMigrationFromUnencryptedData() throws { + // Given: Plaintext data directly in keychain (simulating master branch) + let testMnemonic = "test mnemonic from master" + let plaintextData = testMnemonic.data(using: .utf8)! + + // Manually insert plaintext into keychain (bypass Keychain.save) + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + kSecAttrAccount as String: "bip39_mnemonic_0", + kSecValueData as String: plaintextData, + kSecAttrAccessGroup as String: Env.keychainGroup, + ] + let status = SecItemAdd(query as CFDictionary, nil) + XCTAssertEqual(status, errSecSuccess) + + // Ensure no encryption key exists + XCTAssertFalse(KeychainCrypto.keyExists()) + + // When: Loading the data using new code + let loaded = try Keychain.loadString(key: .bip39Mnemonic(index: 0)) + + // Then: Should successfully load plaintext + XCTAssertEqual(loaded, testMnemonic) + } + + func testMigrationAutoEncryptsOnNextSave() throws { + // Given: Legacy plaintext in keychain + let plaintextData = "legacy".data(using: .utf8)! + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + kSecAttrAccount as String: "bip39_mnemonic_1", + kSecValueData as String: plaintextData, + kSecAttrAccessGroup as String: Env.keychainGroup, + ] + SecItemAdd(query as CFDictionary, nil) + + // Load legacy data (does not create encryption key, just returns plaintext) + let loaded = try Keychain.loadString(key: .bip39Mnemonic(index: 1)) + XCTAssertEqual(loaded, "legacy") + + // When: Deleting and re-saving + try Keychain.delete(key: .bip39Mnemonic(index: 1)) + try Keychain.saveString(key: .bip39Mnemonic(index: 1), str: "new encrypted") + + // Then: Data should now be encrypted + XCTAssertTrue(KeychainCrypto.keyExists()) + + // Verify by trying to read raw keychain data - it should be encrypted + var dataTypeRef: AnyObject? + let loadQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: "bip39_mnemonic_1", + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecAttrAccessGroup as String: Env.keychainGroup, + ] + SecItemCopyMatching(loadQuery as CFDictionary, &dataTypeRef) + let rawData = dataTypeRef as! Data + + // Raw data should NOT be plaintext "new encrypted" + let plaintextAttempt = String(data: rawData, encoding: .utf8) + XCTAssertNotEqual(plaintextAttempt, "new encrypted", "Data should be encrypted") + } + + func testDecryptionFailsWithCorruptedDataWhenKeyExists() throws { + // Given: Encryption key exists and encrypted data is saved + try Keychain.saveString(key: .bip39Mnemonic(index: 2), str: "test") + XCTAssertTrue(KeychainCrypto.keyExists()) + + // When: Manually corrupting the encrypted data in keychain + var dataTypeRef: AnyObject? + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: "bip39_mnemonic_2", + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecAttrAccessGroup as String: Env.keychainGroup, + ] + SecItemCopyMatching(query as CFDictionary, &dataTypeRef) + var corruptedData = dataTypeRef as! Data + corruptedData[corruptedData.count - 1] ^= 0xFF // Flip bits + + // Update keychain with corrupted data + let updateQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: "bip39_mnemonic_2", + kSecAttrAccessGroup as String: Env.keychainGroup, + ] + let updateAttrs: [String: Any] = [kSecValueData as String: corruptedData] + SecItemUpdate(updateQuery as CFDictionary, updateAttrs as CFDictionary) + + // Then: Should throw failedToDecrypt (not return plaintext) + XCTAssertThrowsError(try Keychain.loadString(key: .bip39Mnemonic(index: 2))) { error in + guard let keychainError = error as? KeychainError else { + XCTFail("Should throw KeychainError") + return + } + XCTAssertEqual(keychainError, .failedToDecrypt) + } } } diff --git a/BitkitTests/KeychainiCloudSyncTests.swift b/BitkitTests/KeychainiCloudSyncTests.swift new file mode 100644 index 00000000..494971cd --- /dev/null +++ b/BitkitTests/KeychainiCloudSyncTests.swift @@ -0,0 +1,109 @@ +@testable import Bitkit +import XCTest + +/// Tests to verify keychain items are NOT synced to iCloud +final class KeychainiCloudSyncTests: XCTestCase { + override func setUpWithError() throws { + try Keychain.wipeEntireKeychain() + try? KeychainCrypto.deleteKey() + } + + override func tearDownWithError() throws { + try? KeychainCrypto.deleteKey() + } + + func testKeychainItemsDoNotSyncToiCloud() throws { + // Given: A test mnemonic + let testMnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + + // When: Saving to keychain + try Keychain.saveString(key: .bip39Mnemonic(index: 0), str: testMnemonic) + + // Then: Verify the keychain item was created with correct attributes + // Query the keychain to check if kSecAttrSynchronizable is set to false + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: "bip39_mnemonic_0", + kSecAttrAccessGroup as String: Env.keychainGroup, + kSecReturnAttributes as String: true, + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + XCTAssertEqual(status, errSecSuccess, "Should find the keychain item") + + guard let attributes = result as? [String: Any] else { + XCTFail("Failed to get keychain item attributes") + return + } + + // Check if synchronizable attribute is set + // If kSecAttrSynchronizable is not present or is false, item won't sync to iCloud + if let synchronizable = attributes[kSecAttrSynchronizable as String] as? Bool { + XCTAssertFalse(synchronizable, "Keychain items MUST NOT sync to iCloud for security") + } else { + // If the attribute is not set, check the accessibility attribute + // kSecAttrAccessibleAfterFirstUnlock allows iCloud sync by default + // We should be using kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly instead + if let accessibility = attributes[kSecAttrAccessible as String] as? String { + // Items with "ThisDeviceOnly" suffix do NOT sync to iCloud + let isThisDeviceOnly = accessibility.contains("ThisDeviceOnly") + || accessibility == (kSecAttrAccessibleWhenUnlockedThisDeviceOnly as String) + || accessibility == (kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly as String) + + XCTAssertTrue( + isThisDeviceOnly, + """ + Keychain items should use 'ThisDeviceOnly' accessibility to prevent iCloud sync. + Current: \(accessibility) + Expected: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + """ + ) + } + } + } + + func testAllKeychainItemTypesDoNotSyncToiCloud() throws { + // Test all keychain item types + let testItems: [(KeychainEntryType, String)] = [ + (.bip39Mnemonic(index: 0), "test mnemonic"), + (.bip39Passphrase(index: 0), "test passphrase"), + (.securityPin, "123456"), + (.pushNotificationPrivateKey, "test_key"), + ] + + for (keyType, value) in testItems { + // Save item + try Keychain.saveString(key: keyType, str: value) + + // Query attributes + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: keyType.storageKey, + kSecAttrAccessGroup as String: Env.keychainGroup, + kSecReturnAttributes as String: true, + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + XCTAssertEqual(status, errSecSuccess, "Should find \(keyType.storageKey)") + + if let attributes = result as? [String: Any], + let accessibility = attributes[kSecAttrAccessible as String] as? String + { + let isThisDeviceOnly = accessibility.contains("ThisDeviceOnly") + || accessibility == (kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly as String) + + XCTAssertTrue( + isThisDeviceOnly, + "\(keyType.storageKey) should NOT sync to iCloud. Current: \(accessibility)" + ) + } + + // Clean up + try Keychain.delete(key: keyType) + } + } +}