From d5c669065aa5958b981052702a6f820f3f4b5637 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 2 Jan 2026 07:53:44 -0300 Subject: [PATCH 01/20] feat: create KeychainCrypto --- Bitkit/Utilities/KeychainCrypto.swift | 123 ++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 Bitkit/Utilities/KeychainCrypto.swift diff --git a/Bitkit/Utilities/KeychainCrypto.swift b/Bitkit/Utilities/KeychainCrypto.swift new file mode 100644 index 00000000..9efedf29 --- /dev/null +++ b/Bitkit/Utilities/KeychainCrypto.swift @@ -0,0 +1,123 @@ +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 { + let networkName = switch Env.network { + case .bitcoin: + "bitcoin" + case .testnet: + "testnet" + case .signet: + "signet" + case .regtest: + "regtest" + } + + return Env.appStorageUrl + .appendingPathComponent(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) + 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") + } + } + + // 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 + } +} From 6cfa94d7e6f8801ca182225b8e6fee6ba499bc71 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 2 Jan 2026 07:59:59 -0300 Subject: [PATCH 02/20] chore: create failedToDecrypt error type --- Bitkit/Utilities/Errors.swift | 1 + 1 file changed, 1 insertion(+) 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 } From ae29e25bbb37b878890e7a08e37448bd454191a5 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 2 Jan 2026 08:06:24 -0300 Subject: [PATCH 03/20] feat: integrate encryption to keychain operations --- Bitkit/Utilities/Keychain.swift | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/Bitkit/Utilities/Keychain.swift b/Bitkit/Utilities/Keychain.swift index 9d129707..86734189 100644 --- a/Bitkit/Utilities/Keychain.swift +++ b/Bitkit/Utilities/Keychain.swift @@ -27,12 +27,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, kSecAttrAccount as String: key.storageKey, - kSecValueData as String: data, + kSecValueData as String: encryptedData, kSecAttrAccessGroup as String: Env.keychainGroup, ] as [String: Any] @@ -51,7 +54,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 @@ -124,8 +127,19 @@ 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 + do { + let decryptedData = try KeychainCrypto.decrypt(encryptedData) + Logger.debug("\(key.storageKey) loaded and decrypted from keychain") + return decryptedData + } catch { + Logger.error("Failed to decrypt \(key.storageKey): \(error)", context: "Keychain") + throw KeychainError.failedToDecrypt + } } class func loadString(key: KeychainEntryType) throws -> String? { From b35897702885d976aaa5389294671f82543b244a Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 2 Jan 2026 08:09:14 -0300 Subject: [PATCH 04/20] feat: handle orphaned keychain scenario --- Bitkit/AppScene.swift | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index add6f520..91bc89ce 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -247,6 +247,9 @@ struct AppScene: View { @Sendable private func setupTask() async { do { + // CRITICAL: Check for orphaned keychain scenario BEFORE wallet exists check + try await handleOrphanedKeychainScenario() + try wallet.setWalletExistsState() // Setup TimedSheetManager with all timed sheets @@ -262,6 +265,26 @@ struct AppScene: View { } } + private func handleOrphanedKeychainScenario() async throws { + let keychainHasMnemonic = try Keychain.exists(key: .bip39Mnemonic(index: 0)) + let encryptionKeyExists = KeychainCrypto.keyExists() + + if keychainHasMnemonic, !encryptionKeyExists { + // ORPHANED STATE: Keychain has data but encryption key is missing + Logger.warn("Detected orphaned keychain state - keychain exists but encryption key missing. Forcing fresh start.", context: "AppScene") + + // Wipe keychain silently (no user toast needed per requirements) + try Keychain.wipeEntireKeychain() + + // Clean App Group UserDefaults + if let appGroupDefaults = UserDefaults(suiteName: "group.bitkit") { + appGroupDefaults.removePersistentDomain(forName: "group.bitkit") + } + + Logger.info("Orphaned keychain wiped. App will show onboarding.", context: "AppScene") + } + } + private func handleNodeLifecycleChange(_ state: NodeLifecycleState) { if state == .initializing { walletIsInitializing = true From 098bdec5b30889a0543fc00c8ee48f0b629da347 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 2 Jan 2026 08:10:10 -0300 Subject: [PATCH 05/20] chore: add keychain key to wipe method --- Bitkit/Utilities/AppReset.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Bitkit/Utilities/AppReset.swift b/Bitkit/Utilities/AppReset.swift index 021122a9..ffe90d2e 100644 --- a/Bitkit/Utilities/AppReset.swift +++ b/Bitkit/Utilities/AppReset.swift @@ -29,11 +29,20 @@ 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) } + // Wipe App Group UserDefaults + if let appGroupDefaults = UserDefaults(suiteName: "group.bitkit") { + appGroupDefaults.removePersistentDomain(forName: "group.bitkit") + Logger.info("Wiped App Group UserDefaults", context: "AppReset") + } + // Wipe logs if Env.network == .regtest { try wipeLogs() From 8fa99f215ced675be3911620e93a29c86cb75a08 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 2 Jan 2026 08:28:52 -0300 Subject: [PATCH 06/20] test: KeychainCryptoTests --- Bitkit.xcodeproj/project.pbxproj | 2 + BitkitTests/KeychainCryptoTests.swift | 304 ++++++++++++++++++++++++++ 2 files changed, 306 insertions(+) create mode 100644 BitkitTests/KeychainCryptoTests.swift diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index cdc9cdef..368b2cd1 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -97,6 +97,7 @@ Utilities/Crypto.swift, Utilities/Errors.swift, Utilities/Keychain.swift, + Utilities/KeychainCrypto.swift, Utilities/Logger.swift, Utilities/StateLocker.swift, ); @@ -120,6 +121,7 @@ Utilities/Crypto.swift, Utilities/Errors.swift, Utilities/Keychain.swift, + Utilities/KeychainCrypto.swift, Utilities/Logger.swift, Utilities/StateLocker.swift, ); diff --git a/BitkitTests/KeychainCryptoTests.swift b/BitkitTests/KeychainCryptoTests.swift new file mode 100644 index 00000000..1bf37a87 --- /dev/null +++ b/BitkitTests/KeychainCryptoTests.swift @@ -0,0 +1,304 @@ +@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 testKeyLoadingFromFile() throws { + // Given: A key has been created and saved + let originalKey = try KeychainCrypto.getOrCreateKey() + + // When: Deleting the cached key and loading again + try KeychainCrypto.deleteKey() + let loadedKey = try KeychainCrypto.getOrCreateKey() + + // Then: Loaded key should match original + var originalData = Data() + var loadedData = Data() + + originalKey.withUnsafeBytes { originalData = Data($0) } + loadedKey.withUnsafeBytes { loadedData = Data($0) } + + // Note: Keys are different after deletion since a new key is created + // This test verifies that getOrCreateKey works after deletion + XCTAssertEqual(loadedData.count, 32) + } + + 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" + ) + } +} From 3810ac560b2c369827026be0b9834162b1a42ac3 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 2 Jan 2026 08:35:32 -0300 Subject: [PATCH 07/20] test: update keychain tests --- BitkitTests/KeychainTests.swift | 145 ++++++++++++++++++++++++++++++-- 1 file changed, 140 insertions(+), 5 deletions(-) diff --git a/BitkitTests/KeychainTests.swift b/BitkitTests/KeychainTests.swift index 86bdaede..198d065d 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,139 @@ 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 testKeychainWithoutEncryptionKeyFails() throws { + // Given: A saved mnemonic with encryption + let testMnemonic = "test mnemonic with encryption" + try Keychain.saveString(key: .bip39Mnemonic(index: 0), str: testMnemonic) + + // When: Deleting the encryption key + try KeychainCrypto.deleteKey() + + // Then: Loading should fail with decryption error + XCTAssertThrowsError(try Keychain.loadString(key: .bip39Mnemonic(index: 0))) { error in + guard let keychainError = error as? KeychainError else { + XCTFail("Should throw KeychainError") + return + } + XCTAssertEqual(keychainError, .failedToDecrypt, "Should fail with decryption error") + } + } + + 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") } } From c0b61c0dea25eb5cf3312b66b8f6a51db89729ac Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 2 Jan 2026 08:43:36 -0300 Subject: [PATCH 08/20] fix: disable icloud data sync --- Bitkit/Utilities/Keychain.swift | 2 +- BitkitTests/KeychainiCloudSyncTests.swift | 116 ++++++++++++++++++++++ 2 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 BitkitTests/KeychainiCloudSyncTests.swift diff --git a/Bitkit/Utilities/Keychain.swift b/Bitkit/Utilities/Keychain.swift index 86734189..1ac32911 100644 --- a/Bitkit/Utilities/Keychain.swift +++ b/Bitkit/Utilities/Keychain.swift @@ -33,7 +33,7 @@ class Keychain { 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: encryptedData, kSecAttrAccessGroup as String: Env.keychainGroup, diff --git a/BitkitTests/KeychainiCloudSyncTests.swift b/BitkitTests/KeychainiCloudSyncTests.swift new file mode 100644 index 00000000..240cacf6 --- /dev/null +++ b/BitkitTests/KeychainiCloudSyncTests.swift @@ -0,0 +1,116 @@ +@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 { + let thisDeviceOnlyAttributes = [ + kSecAttrAccessibleWhenUnlocked as String, + kSecAttrAccessibleAfterFirstUnlock as String, + kSecAttrAccessibleWhenUnlockedThisDeviceOnly as String, + kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly 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) + } + } +} From ab645ca88d50f1db1faece2dfa393b2c0de239e3 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 2 Jan 2026 11:10:59 -0300 Subject: [PATCH 09/20] fix: handle migration --- Bitkit/Utilities/Keychain.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Bitkit/Utilities/Keychain.swift b/Bitkit/Utilities/Keychain.swift index 1ac32911..20f264ca 100644 --- a/Bitkit/Utilities/Keychain.swift +++ b/Bitkit/Utilities/Keychain.swift @@ -137,6 +137,13 @@ class Keychain { Logger.debug("\(key.storageKey) loaded and decrypted from keychain") return decryptedData } catch { + // Migration: If decryption fails and no encryption key exists, this is legacy plaintext data + if !KeychainCrypto.keyExists() { + 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 but decryption failed → truly corrupted/orphaned Logger.error("Failed to decrypt \(key.storageKey): \(error)", context: "Keychain") throw KeychainError.failedToDecrypt } From 9578348747ba444add427bd59896bf59feef30a4 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 2 Jan 2026 11:21:25 -0300 Subject: [PATCH 10/20] fix: check if encryption key exists BEFORE attempting decryption --- Bitkit/Utilities/Keychain.swift | 17 ++-- BitkitTests/KeychainTests.swift | 139 +++++++++++++++++++++++++++++--- 2 files changed, 139 insertions(+), 17 deletions(-) diff --git a/Bitkit/Utilities/Keychain.swift b/Bitkit/Utilities/Keychain.swift index 20f264ca..e63bd751 100644 --- a/Bitkit/Utilities/Keychain.swift +++ b/Bitkit/Utilities/Keychain.swift @@ -132,18 +132,21 @@ class Keychain { } // 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 { - // Migration: If decryption fails and no encryption key exists, this is legacy plaintext data - if !KeychainCrypto.keyExists() { - 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 but decryption failed → truly corrupted/orphaned + // Decryption failed with existing key → truly corrupted/orphaned data Logger.error("Failed to decrypt \(key.storageKey): \(error)", context: "Keychain") throw KeychainError.failedToDecrypt } diff --git a/BitkitTests/KeychainTests.swift b/BitkitTests/KeychainTests.swift index 198d065d..1f4d4153 100644 --- a/BitkitTests/KeychainTests.swift +++ b/BitkitTests/KeychainTests.swift @@ -81,22 +81,36 @@ final class KeychainTests: XCTestCase { XCTAssertEqual(retrieved, testMnemonic, "Retrieved data should match original") } - func testKeychainWithoutEncryptionKeyFails() throws { + func testKeychainWithoutEncryptionKeyReturnsEncryptedData() throws { // Given: A saved mnemonic with encryption let testMnemonic = "test mnemonic with encryption" try Keychain.saveString(key: .bip39Mnemonic(index: 0), str: testMnemonic) - // When: Deleting the encryption key + // 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 should fail with decryption error - XCTAssertThrowsError(try Keychain.loadString(key: .bip39Mnemonic(index: 0))) { error in - guard let keychainError = error as? KeychainError else { - XCTFail("Should throw KeychainError") - return - } - XCTAssertEqual(keychainError, .failedToDecrypt, "Should fail with decryption error") - } + // 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 { @@ -192,4 +206,109 @@ final class KeychainTests: XCTestCase { 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) + } + } } From e47ce9af75029d2a222fd2c49a3fa5c9a888022d Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 2 Jan 2026 13:32:40 -0300 Subject: [PATCH 11/20] chore: extract app group identifier --- Bitkit/AppScene.swift | 7 ++----- Bitkit/Constants/Env.swift | 3 ++- Bitkit/Models/ReceivedTxSheetDetails.swift | 2 +- Bitkit/Utilities/AppReset.swift | 4 ++-- 4 files changed, 7 insertions(+), 9 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 91bc89ce..8f5fd219 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -270,15 +270,12 @@ struct AppScene: View { let encryptionKeyExists = KeychainCrypto.keyExists() if keychainHasMnemonic, !encryptionKeyExists { - // ORPHANED STATE: Keychain has data but encryption key is missing Logger.warn("Detected orphaned keychain state - keychain exists but encryption key missing. Forcing fresh start.", context: "AppScene") - // Wipe keychain silently (no user toast needed per requirements) try Keychain.wipeEntireKeychain() - // Clean App Group UserDefaults - if let appGroupDefaults = UserDefaults(suiteName: "group.bitkit") { - appGroupDefaults.removePersistentDomain(forName: "group.bitkit") + if let appGroupDefaults = UserDefaults(suiteName: Env.appGroupIdentifier) { + appGroupDefaults.removePersistentDomain(forName: Env.appGroupIdentifier) } Logger.info("Orphaned keychain wiped. App will show onboarding.", context: "AppScene") diff --git a/Bitkit/Constants/Env.swift b/Bitkit/Constants/Env.swift index ef1f6e4c..bd2634f9 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" @@ -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/Utilities/AppReset.swift b/Bitkit/Utilities/AppReset.swift index ffe90d2e..e17f747b 100644 --- a/Bitkit/Utilities/AppReset.swift +++ b/Bitkit/Utilities/AppReset.swift @@ -38,8 +38,8 @@ enum AppReset { } // Wipe App Group UserDefaults - if let appGroupDefaults = UserDefaults(suiteName: "group.bitkit") { - appGroupDefaults.removePersistentDomain(forName: "group.bitkit") + if let appGroupDefaults = UserDefaults(suiteName: Env.appGroupIdentifier) { + appGroupDefaults.removePersistentDomain(forName: Env.appGroupIdentifier) Logger.info("Wiped App Group UserDefaults", context: "AppReset") } From af84f117eaeaf87e741f5d33b295fa14b7efe753 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 2 Jan 2026 13:38:26 -0300 Subject: [PATCH 12/20] chore: convert var to let --- Bitkit/Utilities/Keychain.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Bitkit/Utilities/Keychain.swift b/Bitkit/Utilities/Keychain.swift index e63bd751..2c9acb28 100644 --- a/Bitkit/Utilities/Keychain.swift +++ b/Bitkit/Utilities/Keychain.swift @@ -55,7 +55,7 @@ class Keychain { } // Sanity check on save - compare decrypted data with original - guard var storedValue = try load(key: key) else { + guard let storedValue = try load(key: key) else { Logger.error("Failed to load \(key.storageKey) after saving", context: "Keychain") throw KeychainError.failedToSave } From 03f937bc58c6e34c4acc9a89cb75ab6fb573766c Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 2 Jan 2026 13:44:48 -0300 Subject: [PATCH 13/20] fix: revert commit --- Bitkit/Utilities/Keychain.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Bitkit/Utilities/Keychain.swift b/Bitkit/Utilities/Keychain.swift index 2c9acb28..e63bd751 100644 --- a/Bitkit/Utilities/Keychain.swift +++ b/Bitkit/Utilities/Keychain.swift @@ -55,7 +55,7 @@ class Keychain { } // Sanity check on save - compare decrypted data with original - guard let storedValue = try load(key: key) else { + guard var storedValue = try load(key: key) else { Logger.error("Failed to load \(key.storageKey) after saving", context: "Keychain") throw KeychainError.failedToSave } From 6943c771c9a412bf76ec39f492b0b6a2d704e75e Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 2 Jan 2026 14:26:43 -0300 Subject: [PATCH 14/20] fix: improve orphaned and migration diferentiation --- Bitkit/AppScene.swift | 47 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 41 insertions(+), 6 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 8f5fd219..c0f39c8a 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -1,3 +1,4 @@ +import BitkitCore import Combine import LDKNode import SwiftUI @@ -270,15 +271,49 @@ struct AppScene: View { let encryptionKeyExists = KeychainCrypto.keyExists() if keychainHasMnemonic, !encryptionKeyExists { - Logger.warn("Detected orphaned keychain state - keychain exists but encryption key missing. Forcing fresh start.", context: "AppScene") + // 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 + } - try Keychain.wipeEntireKeychain() + // 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 - if let appGroupDefaults = UserDefaults(suiteName: Env.appGroupIdentifier) { - appGroupDefaults.removePersistentDomain(forName: Env.appGroupIdentifier) - } + // 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" + ) - Logger.info("Orphaned keychain wiped. App will show onboarding.", context: "AppScene") + try Keychain.wipeEntireKeychain() + + if let appGroupDefaults = UserDefaults(suiteName: Env.appGroupIdentifier) { + appGroupDefaults.removePersistentDomain(forName: Env.appGroupIdentifier) + } + + Logger.info("Orphaned keychain wiped. App will show onboarding.", context: "AppScene") + } catch { + Logger.error("Failed to load keychain during orphaned check: \(error).", context: "AppScene") + } } } From 1ab07381a2ed65aabc5c5c702694b56852f06f4c Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 2 Jan 2026 15:00:10 -0300 Subject: [PATCH 15/20] chore: remove unused attribute --- BitkitTests/KeychainiCloudSyncTests.swift | 7 ------- 1 file changed, 7 deletions(-) diff --git a/BitkitTests/KeychainiCloudSyncTests.swift b/BitkitTests/KeychainiCloudSyncTests.swift index 240cacf6..494971cd 100644 --- a/BitkitTests/KeychainiCloudSyncTests.swift +++ b/BitkitTests/KeychainiCloudSyncTests.swift @@ -47,13 +47,6 @@ final class KeychainiCloudSyncTests: XCTestCase { // kSecAttrAccessibleAfterFirstUnlock allows iCloud sync by default // We should be using kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly instead if let accessibility = attributes[kSecAttrAccessible as String] as? String { - let thisDeviceOnlyAttributes = [ - kSecAttrAccessibleWhenUnlocked as String, - kSecAttrAccessibleAfterFirstUnlock as String, - kSecAttrAccessibleWhenUnlockedThisDeviceOnly as String, - kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly as String, - ] - // Items with "ThisDeviceOnly" suffix do NOT sync to iCloud let isThisDeviceOnly = accessibility.contains("ThisDeviceOnly") || accessibility == (kSecAttrAccessibleWhenUnlockedThisDeviceOnly as String) From e548bc3aab01defe0fa863812b9beef12196f58e Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 6 Jan 2026 17:59:28 -0300 Subject: [PATCH 16/20] fix: wipe RN keychain after migration --- Bitkit/AppScene.swift | 15 +++++++--- Bitkit/Services/MigrationsService.swift | 37 ++++++++++++++++++++++++- 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index babcfa3a..7b723d64 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -287,9 +287,11 @@ 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() - // CRITICAL: Check for orphaned keychain scenario BEFORE wallet exists check + // Now check for orphaned keychain (after migration has run) try await handleOrphanedKeychainScenario() try wallet.setWalletExistsState() @@ -314,8 +316,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 } @@ -421,11 +425,14 @@ struct AppScene: View { 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. App will show onboarding.", context: "AppScene") + 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") } diff --git a/Bitkit/Services/MigrationsService.swift b/Bitkit/Services/MigrationsService.swift index 28197566..23981409 100644 --- a/Bitkit/Services/MigrationsService.swift +++ b/Bitkit/Services/MigrationsService.swift @@ -400,6 +400,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 @@ -464,7 +495,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 { From a6e94a3ffa7d436996fe0a0a559ab69f59df89f7 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 6 Jan 2026 18:07:20 -0300 Subject: [PATCH 17/20] fix: don't create another setup if one succeeded --- Bitkit/Services/VssBackupClient.swift | 1 + 1 file changed, 1 insertion(+) 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 From 751a505f2128b5261953c2028f7e5183b1780dcd Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 6 Jan 2026 18:07:49 -0300 Subject: [PATCH 18/20] fix: prevent backup triggering during migrations --- Bitkit/Services/MigrationsService.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Bitkit/Services/MigrationsService.swift b/Bitkit/Services/MigrationsService.swift index 23981409..b83f9410 100644 --- a/Bitkit/Services/MigrationsService.swift +++ b/Bitkit/Services/MigrationsService.swift @@ -478,6 +478,10 @@ extension MigrationsService { func migrateFromReactNative(walletIndex: Int = 0) async throws { Logger.info("Starting RN migration", context: "Migration") + // Prevent backups from triggering during migration + BackupService.shared.setWiping(true) + defer { BackupService.shared.setWiping(false) } + try migrateMnemonic(walletIndex: walletIndex) try migratePassphrase(walletIndex: walletIndex) try migratePin() From 92920e0021ce6e374191a8a83b9b6272ee015239 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 7 Jan 2026 15:11:25 -0300 Subject: [PATCH 19/20] fix: check for orphaned RN data before doing the migration --- Bitkit/AppScene.swift | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 7b723d64..2c6ba576 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -330,8 +330,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() From 3000a78faea8a389c308abe1554d87cb7a5f49c6 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 7 Jan 2026 15:53:35 -0300 Subject: [PATCH 20/20] fix: add conditional compilation to prevent BackupService being compiled on tests --- Bitkit/Services/MigrationsService.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Bitkit/Services/MigrationsService.swift b/Bitkit/Services/MigrationsService.swift index b83f9410..28721174 100644 --- a/Bitkit/Services/MigrationsService.swift +++ b/Bitkit/Services/MigrationsService.swift @@ -479,8 +479,10 @@ extension MigrationsService { Logger.info("Starting RN migration", context: "Migration") // Prevent backups from triggering during migration - BackupService.shared.setWiping(true) - defer { BackupService.shared.setWiping(false) } + #if !UNIT_TESTING + BackupService.shared.setWiping(true) + defer { BackupService.shared.setWiping(false) } + #endif try migrateMnemonic(walletIndex: walletIndex) try migratePassphrase(walletIndex: walletIndex)