Skip to content
Closed
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
d5c6690
feat: create KeychainCrypto
jvsena42 Jan 2, 2026
6cfa94d
chore: create failedToDecrypt error type
jvsena42 Jan 2, 2026
ae29e25
feat: integrate encryption to keychain operations
jvsena42 Jan 2, 2026
b358977
feat: handle orphaned keychain scenario
jvsena42 Jan 2, 2026
098bdec
chore: add keychain key to wipe method
jvsena42 Jan 2, 2026
8fa99f2
test: KeychainCryptoTests
jvsena42 Jan 2, 2026
3810ac5
test: update keychain tests
jvsena42 Jan 2, 2026
c0b61c0
fix: disable icloud data sync
jvsena42 Jan 2, 2026
ab645ca
fix: handle migration
jvsena42 Jan 2, 2026
9578348
fix: check if encryption key exists BEFORE attempting decryption
jvsena42 Jan 2, 2026
e47ce9a
chore: extract app group identifier
jvsena42 Jan 2, 2026
af84f11
chore: convert var to let
jvsena42 Jan 2, 2026
03f937b
fix: revert commit
jvsena42 Jan 2, 2026
6943c77
fix: improve orphaned and migration diferentiation
jvsena42 Jan 2, 2026
1ab0738
chore: remove unused attribute
jvsena42 Jan 2, 2026
b855dfb
Merge branch 'master' into fix/conflicts
jvsena42 Jan 6, 2026
e548bc3
fix: wipe RN keychain after migration
jvsena42 Jan 6, 2026
a6e94a3
fix: don't create another setup if one succeeded
jvsena42 Jan 6, 2026
751a505
fix: prevent backup triggering during migrations
jvsena42 Jan 6, 2026
f0dacd1
Merge branch 'master' into fix/clean-keychain-persistence-uninstall
jvsena42 Jan 7, 2026
3d3a28f
Merge branch 'fix/clean-keychain-persistence-uninstall' of github.com…
jvsena42 Jan 7, 2026
d2d11bf
Merge branch 'fix/pin-not-accepted' into fix/clean-keychain-persisten…
jvsena42 Jan 7, 2026
92920e0
fix: check for orphaned RN data before doing the migration
jvsena42 Jan 7, 2026
3000a78
fix: add conditional compilation to prevent BackupService being compi…
jvsena42 Jan 7, 2026
9a48db5
Merge branch 'master' into fix/clean-keychain-persistence-uninstall
jvsena42 Jan 8, 2026
613a97e
Merge branch 'master' into fix/clean-keychain-persistence-uninstall
jvsena42 Jan 8, 2026
cfba886
Merge branch 'master' into fix/clean-keychain-persistence-uninstall
jvsena42 Jan 9, 2026
335daf9
Merge branch 'master' into fix/clean-keychain-persistence-uninstall
jvsena42 Jan 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Bitkit.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@
Utilities/Crypto.swift,
Utilities/Errors.swift,
Utilities/Keychain.swift,
Utilities/KeychainCrypto.swift,
Utilities/Logger.swift,
Utilities/StateLocker.swift,
);
Expand All @@ -120,6 +121,7 @@
Utilities/Crypto.swift,
Utilities/Errors.swift,
Utilities/Keychain.swift,
Utilities/KeychainCrypto.swift,
Utilities/Logger.swift,
Utilities/StateLocker.swift,
);
Expand Down
23 changes: 23 additions & 0 deletions Bitkit/AppScene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
9 changes: 9 additions & 0 deletions Bitkit/Utilities/AppReset.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions Bitkit/Utilities/Errors.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ enum KeychainError: Error {
case failedToSaveAlreadyExists
case failedToDelete
case failedToLoad
case failedToDecrypt
case keychainWipeNotAllowed
}

Expand Down
24 changes: 19 additions & 5 deletions Bitkit/Utilities/Keychain.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly as String,
kSecAttrAccount as String: key.storageKey,
kSecValueData as String: data,
kSecValueData as String: encryptedData,
kSecAttrAccessGroup as String: Env.keychainGroup,
] as [String: Any]

Expand All @@ -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
Expand Down Expand Up @@ -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? {
Expand Down
123 changes: 123 additions & 0 deletions Bitkit/Utilities/KeychainCrypto.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading