Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Bitkit.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@
Utilities/Crypto.swift,
Utilities/Errors.swift,
Utilities/Keychain.swift,
Utilities/KeychainCrypto.swift,
Utilities/Logger.swift,
Utilities/StateLocker.swift,
);
Expand All @@ -121,6 +122,7 @@
Utilities/Crypto.swift,
Utilities/Errors.swift,
Utilities/Keychain.swift,
Utilities/KeychainCrypto.swift,
Utilities/Logger.swift,
Utilities/StateLocker.swift,
);
Expand Down
55 changes: 55 additions & 0 deletions Bitkit/AppScene.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import BitkitCore
import Combine
import LDKNode
import SwiftUI
Expand Down Expand Up @@ -287,6 +288,9 @@ struct AppScene: View {
private func setupTask() async {
do {
await checkAndPerformRNMigration()

// CRITICAL: Check for orphaned keychain scenario BEFORE wallet exists check
try await handleOrphanedKeychainScenario()
try wallet.setWalletExistsState()

// Setup TimedSheetManager with all timed sheets
Expand Down Expand Up @@ -377,6 +381,57 @@ struct AppScene: View {
}
}

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

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

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

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

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

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

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

try Keychain.wipeEntireKeychain()

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")
}
}
}

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

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

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

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

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

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

func save() {
do {
Expand Down
10 changes: 9 additions & 1 deletion Bitkit/Utilities/AppReset.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,22 @@ enum AppReset {
// Wipe keychain
try Keychain.wipeEntireKeychain()

// Wipe encryption key
try KeychainCrypto.deleteKey()

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

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

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

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

Expand Down Expand Up @@ -338,7 +339,7 @@
case let .InvalidNodeAlias(message: ldkMessage):
message = "Invalid node alias"
debugMessage = ldkMessage
case let .InvalidCustomTlvs(message: ldkMessage):

Check warning on line 342 in Bitkit/Utilities/Errors.swift

View workflow job for this annotation

GitHub Actions / Run Integration Tests

case is already handled by previous patterns; consider removing it
message = "Invalid custom TLVs"
debugMessage = ldkMessage
case let .InvalidDateTime(message: ldkMessage):
Expand Down
34 changes: 29 additions & 5 deletions Bitkit/Utilities/Keychain.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,15 @@ class Keychain {
class func save(key: KeychainEntryType, data: Data) throws {
Logger.debug("Saving \(key.storageKey)", context: "Keychain")

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

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

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

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

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

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

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

class func loadString(key: KeychainEntryType) throws -> String? {
Expand Down
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
Loading