Compare commits

...

3 Commits

Author SHA1 Message Date
Nick Klockenga
84c02cc6dd
version bump (#31)
Some checks failed
Xcode - Build and Analyze / Build and analyse default scheme using xcodebuild command (push) Has been cancelled
Xcode - Unit Tests / Run unit tests using xcodebuild (push) Has been cancelled
Reproducible Build Check / Verify build reproducibility (push) Has been cancelled
SwiftFormat Check / Check code formatting with SwiftFormat (push) Has been cancelled
2026-04-30 23:17:15 -04:00
Nick Klockenga
fac6560531
Security PIN for app lock had bugs, fixed up syncing of state issues across settings and app foreground/launch (#30) 2026-04-30 23:14:54 -04:00
Nick Klockenga
33b7e491c2
handle Vpub/tpub/Zpub/xpub conversion correctly when creating a wallet. Also adding unit test for checking descriptor build (#29) 2026-04-30 23:14:35 -04:00
9 changed files with 582 additions and 118 deletions

View File

@ -342,7 +342,7 @@
isa = XCBuildConfiguration;
baseConfigurationReference = CC0000010000000000000004 /* Birch.xcconfig */;
buildSettings = {
CURRENT_PROJECT_VERSION = 25;
CURRENT_PROJECT_VERSION = 26;
MARKETING_VERSION = 0.2.0;
};
name = Debug;
@ -351,7 +351,7 @@
isa = XCBuildConfiguration;
baseConfigurationReference = CC0000010000000000000004 /* Birch.xcconfig */;
buildSettings = {
CURRENT_PROJECT_VERSION = 25;
CURRENT_PROJECT_VERSION = 26;
MARKETING_VERSION = 0.2.0;
};
name = Release;

View File

@ -1,87 +0,0 @@
{
"originHash" : "11f3c5d73e6615e055e5b9f3671e6180f277a34f298c3f7c6935dcc8dd281089",
"pins" : [
{
"identity" : "bbqr-swift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/bitcoinppl/bbqr-swift",
"state" : {
"revision" : "83b828077ecc4f5d2cf8889da5543a61b4a60a3c",
"version" : "0.3.1"
}
},
{
"identity" : "bcswiftdcbor",
"kind" : "remoteSourceControl",
"location" : "https://github.com/BlockchainCommons/BCSwiftDCBOR",
"state" : {
"revision" : "21efa67ada2f22a6c277e1961f1059bb376e9b1a",
"version" : "2.0.2"
}
},
{
"identity" : "bcswiftfloat16",
"kind" : "remoteSourceControl",
"location" : "https://github.com/blockchaincommons/BCSwiftFloat16",
"state" : {
"revision" : "a27f3935a7b1db715713eda67369b02feade2ded",
"version" : "2.0.0"
}
},
{
"identity" : "bcswifttags",
"kind" : "remoteSourceControl",
"location" : "https://github.com/BlockchainCommons/BCSwiftTags",
"state" : {
"revision" : "ced8d92c7cc53375cdf9806c59251fe0161f02ec",
"version" : "0.2.3"
}
},
{
"identity" : "bdk-swift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/newtonick/bdk-swift",
"state" : {
"revision" : "4660bc83ea6088906edb090652d261e8ed4c09e3",
"version" : "2.3.1-ssl-patch"
}
},
{
"identity" : "swift-numberkit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/objecthub/swift-numberkit.git",
"state" : {
"revision" : "33af3f9011e45dcd8ee696492d30dbcd5a8a67f3",
"version" : "2.6.0"
}
},
{
"identity" : "swiftsortedcollections",
"kind" : "remoteSourceControl",
"location" : "https://github.com/wolfmcnally/SwiftSortedCollections",
"state" : {
"revision" : "dd6c8e0eaef987e55a35c056d185144a7c71fc19",
"version" : "0.1.0"
}
},
{
"identity" : "urkit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/BlockchainCommons/URKit",
"state" : {
"revision" : "c0a447560768e2552cf85a586dea8cfc26162891",
"version" : "15.1.0"
}
},
{
"identity" : "urui",
"kind" : "remoteSourceControl",
"location" : "https://github.com/BlockchainCommons/URUI",
"state" : {
"revision" : "c1b0ac2d0ba77741f00f439d311e7c85ee26a70a",
"version" : "12.0.0"
}
}
],
"version" : 3
}

View File

@ -1333,12 +1333,18 @@ final class BitcoinService {
) -> String {
let chain = isChange ? "1" : "0"
let coinType = network.coinType
let isTestnet = network != .mainnet
let sorted = cosigners.sorted { $0.xpub < $1.xpub }
let normalized = cosigners.map { cosigner -> (xpub: String, fingerprint: String, derivationPath: String) in
let raw = cosigner.xpub.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
let xpub = URService.normalizeXpub(raw, isTestnet: isTestnet) ?? raw
return (xpub: xpub, fingerprint: cosigner.fingerprint, derivationPath: cosigner.derivationPath)
}
let sorted = normalized.sorted { $0.xpub < $1.xpub }
let keys = sorted.map { cosigner in
let xpub = cosigner.xpub.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
return "[\(cosigner.fingerprint)/48'/\(coinType)'/0'/2']\(xpub)/\(chain)/*"
"[\(cosigner.fingerprint)/48'/\(coinType)'/0'/2']\(cosigner.xpub)/\(chain)/*"
}.joined(separator: ",")
return "wsh(sortedmulti(\(requiredSignatures),\(keys)))"
@ -1351,11 +1357,20 @@ final class BitcoinService {
network: BitcoinNetwork
) -> String {
let coinType = network.coinType
let sorted = cosigners.sorted { $0.xpub < $1.xpub }
let isTestnet = network != .mainnet
// Normalize each cosigner xpub to standard xpub/tpub format (BDK descriptor
// parser does not accept SLIP132-tagged Vpub/Zpub/Ypub/Upub keys).
let normalized = cosigners.map { cosigner -> (xpub: String, fingerprint: String, derivationPath: String) in
let raw = cosigner.xpub.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
let xpub = URService.normalizeXpub(raw, isTestnet: isTestnet) ?? raw
return (xpub: xpub, fingerprint: cosigner.fingerprint, derivationPath: cosigner.derivationPath)
}
let sorted = normalized.sorted { $0.xpub < $1.xpub }
let keys = sorted.map { cosigner in
let xpub = cosigner.xpub.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
return "[\(cosigner.fingerprint)/48'/\(coinType)'/0'/2']\(xpub)/<0;1>/*"
"[\(cosigner.fingerprint)/48'/\(coinType)'/0'/2']\(cosigner.xpub)/<0;1>/*"
}.joined(separator: ",")
let raw = "wsh(sortedmulti(\(requiredSignatures),\(keys)))"

View File

@ -1,7 +1,15 @@
import Foundation
import Security
enum KeychainHelper {
protocol KeychainStoring {
@discardableResult
static func save(_ data: Data, forKey key: String) -> Bool
static func load(forKey key: String) -> Data?
static func delete(forKey key: String)
static func deleteAll()
}
enum KeychainHelper: KeychainStoring {
private static let service = Bundle.main.bundleIdentifier ?? "com.hellbender"
@discardableResult

View File

@ -18,6 +18,7 @@ final class AppLockViewModel {
private(set) var failedAttempts: Int = 0
private(set) var lockoutExpiry: Date?
private var backgroundTime: Date?
private let keychain: KeychainStoring.Type
// MARK: - Computed
@ -47,9 +48,10 @@ final class AppLockViewModel {
// MARK: - Init
init() {
hasPIN = KeychainHelper.load(forKey: Constants.keychainPINHashKey) != nil
if let data = KeychainHelper.load(forKey: Constants.keychainPINLengthKey),
init(keychain: KeychainStoring.Type = KeychainHelper.self) {
self.keychain = keychain
hasPIN = keychain.load(forKey: Constants.keychainPINHashKey) != nil
if let data = keychain.load(forKey: Constants.keychainPINLengthKey),
let str = String(data: data, encoding: .utf8),
let len = Int(str)
{
@ -102,7 +104,7 @@ final class AppLockViewModel {
return false
}
guard let storedHash = KeychainHelper.load(forKey: Constants.keychainPINHashKey) else {
guard let storedHash = keychain.load(forKey: Constants.keychainPINHashKey) else {
return false
}
@ -138,8 +140,8 @@ final class AppLockViewModel {
func setPIN(_ pin: String) {
logger.info("PIN set (\(pin.count) digits)")
let hash = hashPIN(pin)
KeychainHelper.save(hash, forKey: Constants.keychainPINHashKey)
KeychainHelper.save(Data("\(pin.count)".utf8), forKey: Constants.keychainPINLengthKey)
keychain.save(hash, forKey: Constants.keychainPINHashKey)
keychain.save(Data("\(pin.count)".utf8), forKey: Constants.keychainPINLengthKey)
failedAttempts = 0
persistFailedAttempts()
lockoutExpiry = nil
@ -150,10 +152,10 @@ final class AppLockViewModel {
func removePIN() {
logger.info("PIN removed")
KeychainHelper.delete(forKey: Constants.keychainPINHashKey)
KeychainHelper.delete(forKey: Constants.keychainPINLengthKey)
KeychainHelper.delete(forKey: Constants.keychainFailedAttemptsKey)
KeychainHelper.delete(forKey: Constants.keychainLockoutExpiryKey)
keychain.delete(forKey: Constants.keychainPINHashKey)
keychain.delete(forKey: Constants.keychainPINLengthKey)
keychain.delete(forKey: Constants.keychainFailedAttemptsKey)
keychain.delete(forKey: Constants.keychainLockoutExpiryKey)
failedAttempts = 0
lockoutExpiry = nil
hasPIN = false
@ -170,6 +172,15 @@ final class AppLockViewModel {
}
func handleForeground(timeout: Int) {
hasPIN = keychain.load(forKey: Constants.keychainPINHashKey) != nil
if let data = keychain.load(forKey: Constants.keychainPINLengthKey),
let str = String(data: data, encoding: .utf8),
let len = Int(str)
{
storedPINLength = len
} else {
storedPINLength = 6
}
if let bgTime = backgroundTime {
let elapsed = Int(Date().timeIntervalSince(bgTime))
if elapsed >= timeout {
@ -208,7 +219,7 @@ final class AppLockViewModel {
}
// Clear Keychain
KeychainHelper.deleteAll()
keychain.deleteAll()
// Reset BitcoinService
BitcoinService.shared.unloadWallet()
@ -245,13 +256,13 @@ final class AppLockViewModel {
}
private func loadPersistedState() {
if let data = KeychainHelper.load(forKey: Constants.keychainFailedAttemptsKey),
if let data = keychain.load(forKey: Constants.keychainFailedAttemptsKey),
let str = String(data: data, encoding: .utf8),
let count = Int(str)
{
failedAttempts = count
}
if let data = KeychainHelper.load(forKey: Constants.keychainLockoutExpiryKey),
if let data = keychain.load(forKey: Constants.keychainLockoutExpiryKey),
let str = String(data: data, encoding: .utf8),
let interval = Double(str)
{
@ -261,14 +272,14 @@ final class AppLockViewModel {
}
private func persistFailedAttempts() {
KeychainHelper.save(Data("\(failedAttempts)".utf8), forKey: Constants.keychainFailedAttemptsKey)
keychain.save(Data("\(failedAttempts)".utf8), forKey: Constants.keychainFailedAttemptsKey)
}
private func persistLockoutExpiry() {
if let expiry = lockoutExpiry {
KeychainHelper.save(Data("\(expiry.timeIntervalSince1970)".utf8), forKey: Constants.keychainLockoutExpiryKey)
keychain.save(Data("\(expiry.timeIntervalSince1970)".utf8), forKey: Constants.keychainLockoutExpiryKey)
} else {
KeychainHelper.delete(forKey: Constants.keychainLockoutExpiryKey)
keychain.delete(forKey: Constants.keychainLockoutExpiryKey)
}
}
}

View File

@ -193,13 +193,17 @@ final class SetupWizardViewModel {
func buildDescriptors() {
guard allCosignersComplete else { return }
// Build key origin strings and sort by xpub (BIP67 lexicographic sort)
// Build key origin strings normalize to standard xpub/tpub format before
// sorting so BIP67 ordering matches what's emitted in the descriptor.
let isTestnet = network != .mainnet
var keyEntries: [(origin: String, xpub: String, fingerprint: String, path: String, label: String, index: Int)] = []
for i in 0 ..< totalCosigners {
let raw = cosignerXpubs[i].trimmingCharacters(in: CharacterSet(charactersIn: "/"))
let normalized = URService.normalizeXpub(raw, isTestnet: isTestnet) ?? raw
keyEntries.append((
origin: "[\(cosignerFingerprints[i])/48'/\(network.coinType)'/0'/2']",
xpub: cosignerXpubs[i],
xpub: normalized,
fingerprint: cosignerFingerprints[i],
path: cosignerDerivationPaths[i],
label: cosignerLabels[i],
@ -211,12 +215,10 @@ final class SetupWizardViewModel {
keyEntries.sort { $0.xpub < $1.xpub }
let externalKeys = keyEntries.map {
let xpub = $0.xpub.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
return "\($0.origin)\(xpub)/0/*"
"\($0.origin)\($0.xpub)/0/*"
}.joined(separator: ",")
let internalKeys = keyEntries.map {
let xpub = $0.xpub.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
return "\($0.origin)\(xpub)/1/*"
"\($0.origin)\($0.xpub)/1/*"
}.joined(separator: ",")
externalDescriptor = "wsh(sortedmulti(\(requiredSignatures),\(externalKeys)))"

View File

@ -0,0 +1,388 @@
@testable import birch
import CryptoKit
import Foundation
import Testing
@Suite("AppLockViewModel")
@MainActor
struct AppLockViewModelTests {
init() {
MockKeychainHelper.reset()
}
private func makeVM() -> AppLockViewModel {
AppLockViewModel(keychain: MockKeychainHelper.self)
}
private func hashPIN(_ pin: String) -> Data {
Data(SHA256.hash(data: Data(pin.utf8)))
}
private func seedPIN(_ pin: String) {
MockKeychainHelper.save(hashPIN(pin), forKey: Constants.keychainPINHashKey)
MockKeychainHelper.save(Data("\(pin.count)".utf8), forKey: Constants.keychainPINLengthKey)
}
private func seedFailedAttempts(_ count: Int) {
MockKeychainHelper.save(Data("\(count)".utf8), forKey: Constants.keychainFailedAttemptsKey)
}
private func seedLockoutExpiry(_ date: Date) {
MockKeychainHelper.save(Data("\(date.timeIntervalSince1970)".utf8), forKey: Constants.keychainLockoutExpiryKey)
}
// MARK: - Initialization
@Test func initWithNoPIN_hasPINIsFalse() {
let vm = makeVM()
#expect(vm.hasPIN == false)
#expect(vm.storedPINLength == 6)
}
@Test func initWithExistingPIN_hasPINIsTrue() {
seedPIN("1234")
let vm = makeVM()
#expect(vm.hasPIN == true)
#expect(vm.storedPINLength == 4)
}
@Test func initWithPersistedFailedAttempts_restoresCount() {
seedFailedAttempts(5)
let vm = makeVM()
#expect(vm.failedAttempts == 5)
}
@Test func initWithExpiredLockout_clearsLockout() {
seedLockoutExpiry(Date().addingTimeInterval(-100))
let vm = makeVM()
#expect(vm.lockoutExpiry == nil)
#expect(vm.isLockedOut == false)
}
@Test func initWithActiveLockout_restoresLockout() {
seedLockoutExpiry(Date().addingTimeInterval(300))
let vm = makeVM()
#expect(vm.lockoutExpiry != nil)
#expect(vm.isLockedOut == true)
}
// MARK: - PIN Management
@Test func setPIN_storesHashAndLength() {
let vm = makeVM()
vm.setPIN("1234")
#expect(vm.hasPIN == true)
#expect(vm.storedPINLength == 4)
#expect(MockKeychainHelper.load(forKey: Constants.keychainPINHashKey) == hashPIN("1234"))
#expect(MockKeychainHelper.load(forKey: Constants.keychainPINLengthKey) == Data("4".utf8))
}
@Test func setPIN_resetsFailedAttempts() {
let vm = makeVM()
vm.setPIN("9999")
_ = vm.verifyPIN("0000")
_ = vm.verifyPIN("0000")
#expect(vm.failedAttempts == 2)
vm.setPIN("5678")
#expect(vm.failedAttempts == 0)
}
@Test func removePIN_clearsKeychainAndState() {
let vm = makeVM()
vm.setPIN("1234")
#expect(vm.hasPIN == true)
vm.removePIN()
#expect(vm.hasPIN == false)
#expect(vm.storedPINLength == 6)
#expect(MockKeychainHelper.load(forKey: Constants.keychainPINHashKey) == nil)
#expect(MockKeychainHelper.load(forKey: Constants.keychainPINLengthKey) == nil)
#expect(MockKeychainHelper.load(forKey: Constants.keychainFailedAttemptsKey) == nil)
#expect(MockKeychainHelper.load(forKey: Constants.keychainLockoutExpiryKey) == nil)
}
@Test func setPIN_removePIN_setPIN_togglesCorrectly() {
let vm = makeVM()
vm.setPIN("1234")
#expect(vm.hasPIN == true)
#expect(vm.storedPINLength == 4)
vm.removePIN()
#expect(vm.hasPIN == false)
#expect(vm.storedPINLength == 6)
vm.setPIN("567890")
#expect(vm.hasPIN == true)
#expect(vm.storedPINLength == 6)
}
// MARK: - PIN Verification
@Test func verifyPIN_correctPIN_returnsTrue() {
let vm = makeVM()
vm.setPIN("5678")
vm.needsPINEntry = true
let result = vm.verifyPIN("5678")
#expect(result == true)
#expect(vm.isLocked == false)
#expect(vm.needsPINEntry == false)
#expect(vm.failedAttempts == 0)
}
@Test func verifyPIN_wrongPIN_returnsFalse() {
let vm = makeVM()
vm.setPIN("5678")
let result = vm.verifyPIN("0000")
#expect(result == false)
#expect(vm.failedAttempts == 1)
}
@Test func verifyPIN_noStoredPIN_returnsFalse() {
let vm = makeVM()
let result = vm.verifyPIN("1234")
#expect(result == false)
}
@Test func verifyPIN_correctPIN_resetsFailedAttempts() {
let vm = makeVM()
vm.setPIN("5678")
_ = vm.verifyPIN("0000")
_ = vm.verifyPIN("0000")
_ = vm.verifyPIN("0000")
#expect(vm.failedAttempts == 3)
let result = vm.verifyPIN("5678")
#expect(result == true)
#expect(vm.failedAttempts == 0)
#expect(vm.lockoutExpiry == nil)
}
@Test func verifyPIN_whileLockedOut_returnsFalse() {
let vm = makeVM()
vm.setPIN("5678")
_ = vm.verifyPIN("0000")
_ = vm.verifyPIN("0000")
_ = vm.verifyPIN("0000")
_ = vm.verifyPIN("0000") // 4th attempt 60s lockout
#expect(vm.isLockedOut == true)
let result = vm.verifyPIN("5678")
#expect(result == false)
#expect(vm.pinError.contains("Try again"))
}
// MARK: - Lockout Progression
@Test func lockout_noLockoutFor1to3Failures() {
let vm = makeVM()
vm.setPIN("5678")
_ = vm.verifyPIN("0000")
#expect(vm.isLockedOut == false)
_ = vm.verifyPIN("0000")
#expect(vm.isLockedOut == false)
_ = vm.verifyPIN("0000")
#expect(vm.isLockedOut == false)
}
@Test func lockout_60sAfter4Failures() throws {
let vm = makeVM()
vm.setPIN("5678")
for _ in 1 ... 4 {
_ = vm.verifyPIN("0000")
}
#expect(vm.isLockedOut == true)
#expect(vm.failedAttempts == 4)
let expiry = try #require(vm.lockoutExpiry)
let delay = expiry.timeIntervalSinceNow
#expect(delay > 55 && delay <= 61)
}
@Test func lockout_10mAfter5Failures() throws {
seedPIN("5678")
seedFailedAttempts(4)
let vm = makeVM()
_ = vm.verifyPIN("0000")
#expect(vm.failedAttempts == 5)
let expiry = try #require(vm.lockoutExpiry)
let delay = expiry.timeIntervalSinceNow
#expect(delay > 595 && delay <= 601)
}
@Test func lockout_90mAfter6Failures() throws {
seedPIN("5678")
seedFailedAttempts(5)
let vm = makeVM()
_ = vm.verifyPIN("0000")
#expect(vm.failedAttempts == 6)
let expiry = try #require(vm.lockoutExpiry)
let delay = expiry.timeIntervalSinceNow
#expect(delay > 5395 && delay <= 5401)
}
@Test func lockout_24hAfter7Failures() throws {
seedPIN("5678")
seedFailedAttempts(6)
let vm = makeVM()
_ = vm.verifyPIN("0000")
#expect(vm.failedAttempts == 7)
let expiry = try #require(vm.lockoutExpiry)
let delay = expiry.timeIntervalSinceNow
#expect(delay > 86395 && delay <= 86401)
}
@Test func lockout_persistsSurvivesReInit() {
let vm = makeVM()
vm.setPIN("5678")
for _ in 1 ... 4 {
_ = vm.verifyPIN("0000")
}
#expect(vm.isLockedOut == true)
let vm2 = makeVM()
#expect(vm2.failedAttempts == 4)
#expect(vm2.isLockedOut == true)
}
@Test func failedAttempts10_reachesWipeThreshold() {
seedPIN("5678")
seedFailedAttempts(9)
let vm = makeVM()
_ = vm.verifyPIN("0000")
#expect(vm.failedAttempts >= 10)
#expect(vm.pinError == "Too many attempts")
}
// MARK: - Background / Foreground
@Test func handleBackground_calledTwice_noOverwrite() {
let vm = makeVM()
vm.isLocked = false
let earlyTime = Date().addingTimeInterval(-120)
vm.handleBackground(at: earlyTime)
vm.handleBackground(at: Date())
vm.handleForeground(timeout: 60)
#expect(vm.isLocked == true)
}
@Test func handleForeground_underTimeout_staysUnlocked() {
let vm = makeVM()
vm.isLocked = false
vm.handleBackground(at: Date())
vm.handleForeground(timeout: 60)
#expect(vm.isLocked == false)
}
@Test func handleForeground_overTimeout_reLocks() {
let vm = makeVM()
vm.isLocked = false
vm.handleBackground(at: Date().addingTimeInterval(-120))
vm.handleForeground(timeout: 60)
#expect(vm.isLocked == true)
}
@Test func handleForeground_rereadsPINState() {
let vm = makeVM()
#expect(vm.hasPIN == false)
seedPIN("1234")
vm.handleForeground(timeout: 60)
#expect(vm.hasPIN == true)
#expect(vm.storedPINLength == 4)
}
@Test func handleForeground_rereadsPINLength_afterRemoval() {
seedPIN("1234")
let vm = makeVM()
#expect(vm.hasPIN == true)
#expect(vm.storedPINLength == 4)
MockKeychainHelper.delete(forKey: Constants.keychainPINHashKey)
MockKeychainHelper.delete(forKey: Constants.keychainPINLengthKey)
vm.handleForeground(timeout: 60)
#expect(vm.hasPIN == false)
#expect(vm.storedPINLength == 6)
}
@Test func handleForeground_noPriorBackground_noRelock() {
let vm = makeVM()
vm.isLocked = false
vm.handleForeground(timeout: 60)
#expect(vm.isLocked == false)
}
// MARK: - Cross-Instance Sync
@Test func crossInstance_setPINOnOne_foregroundReadsOnOther() {
let vmA = makeVM()
let vmB = makeVM()
vmA.setPIN("1234")
#expect(vmA.hasPIN == true)
#expect(vmB.hasPIN == false)
vmB.handleForeground(timeout: 60)
#expect(vmB.hasPIN == true)
#expect(vmB.storedPINLength == 4)
}
@Test func crossInstance_removePINOnOne_foregroundReadsOnOther() {
seedPIN("5678")
let vmA = makeVM()
let vmB = makeVM()
#expect(vmA.hasPIN == true)
#expect(vmB.hasPIN == true)
vmA.removePIN()
#expect(vmA.hasPIN == false)
#expect(vmB.hasPIN == true)
vmB.handleForeground(timeout: 60)
#expect(vmB.hasPIN == false)
#expect(vmB.storedPINLength == 6)
}
@Test func crossInstance_setPIN_thenTimeout_showsCorrectPINLength() {
let vmSettings = makeVM()
let vmLock = makeVM()
vmLock.isLocked = false
vmSettings.setPIN("12345678")
#expect(vmLock.storedPINLength == 6)
vmLock.handleBackground(at: Date().addingTimeInterval(-120))
vmLock.handleForeground(timeout: 60)
#expect(vmLock.hasPIN == true)
#expect(vmLock.storedPINLength == 8)
#expect(vmLock.isLocked == true)
}
// MARK: - Lockout Text
@Test func lockoutRemainingText_noLockout_empty() {
let vm = makeVM()
#expect(vm.lockoutRemainingText == "")
}
@Test func lockoutRemainingText_showsSeconds() {
seedLockoutExpiry(Date().addingTimeInterval(30))
let vm = makeVM()
let text = vm.lockoutRemainingText
#expect(text.contains("30s") || text.contains("29s"))
}
@Test func lockoutRemainingText_showsMinutes() {
seedLockoutExpiry(Date().addingTimeInterval(300))
let vm = makeVM()
let text = vm.lockoutRemainingText
#expect(text.contains("5m") || text.contains("4m"))
}
@Test func lockoutRemainingText_showsHoursAndMinutes() {
seedLockoutExpiry(Date().addingTimeInterval(7260))
let vm = makeVM()
let text = vm.lockoutRemainingText
#expect(text.contains("2h"))
}
// MARK: - Face ID Retry State Reset
@Test func faceIDRetry_clearsState() {
let vm = makeVM()
vm.needsPINEntry = true
vm.pinInput = "12"
vm.pinError = "Incorrect PIN"
vm.needsPINEntry = false
vm.pinInput = ""
vm.pinError = ""
#expect(vm.needsPINEntry == false)
#expect(vm.pinInput == "")
#expect(vm.pinError == "")
}
}

View File

@ -211,6 +211,105 @@ struct DescriptorTests {
#expect(desc.contains("[73c5da0a/48'/1'/0'/2']"), "Should contain cosigner fingerprint/path")
}
// MARK: - SLIP132 Vpub/Zpub normalization
/// Cosigners as they might be entered by the user first one is in SLIP132
/// `Vpub` format (BIP-84 wsh testnet), the other two are standard `tpub`.
/// BDK's descriptor parser only accepts `xpub`/`tpub`, so the descriptor
/// builder must normalize the `Vpub` to `tpub` before assembly.
private static let mixedFormatCosigners: [(xpub: String, fingerprint: String, derivationPath: String)] = [
(xpub: "Vpub5kv6Y3xqGFyhZQyCz8LzaSwVzAJLJTvHcUewWAhrLRRRjZeYs53qrfspVEBKZw6rvwGy8Z1ef7e7Vzsu3BLF6MkjFXWnLpmftKQT1Eub5Cf",
fingerprint: "d03ce438", derivationPath: "m/48'/1'/0'/2'"),
(xpub: "tpubDE2JvCZ3g8tEX3yegvXFn9cpzUyA2EEg6EwS7sAHcPER9yA6nFKdGPyLzsswYWa3SvEbKFmUiyFe9QQrpVpKwxojCud4ThNEv8R3j411Lcs",
fingerprint: "f9755e5b", derivationPath: "m/48'/1'/0'/2'"),
(xpub: "tpubDFEegnzQJr8LdYmGh1dGy3vqVgWtZ5w6q2cw4fbXhp15A29hvpf4NtAeFNvmmDRFTzeu1CveXs6dK2iPVADn2fSXWAQhHZhtLRGeHLmiBi5",
fingerprint: "acc95047", derivationPath: "m/48'/1'/0'/2'"),
]
/// The expected `tpub` form of the `Vpub` from `mixedFormatCosigners[0]`.
private static let convertedTpub =
"tpubDE4AYPPuhwTk7ENvANSMNU84wRecxjikg4e1WFHE4a6fxsNogCqnA7zzxyDoXp93JeyWNViXEKnkqaysaCrZRnTZDLYXnmbt7zrGxWYc3Mx"
@Test func combinedDescriptorNormalizesVpubToTpub() {
let desc = BitcoinService.buildCombinedDescriptor(
requiredSignatures: 2,
cosigners: Self.mixedFormatCosigners,
network: .testnet4
)
// No SLIP132-tagged keys should remain in the assembled descriptor.
#expect(!desc.contains("Vpub"), "Descriptor should not contain SLIP132 Vpub keys after normalization")
#expect(!desc.contains("Zpub"), "Descriptor should not contain SLIP132 Zpub keys after normalization")
// The converted tpub from the original Vpub must be present, paired with
// the cosigner's original fingerprint.
#expect(desc.contains(Self.convertedTpub), "Vpub should normalize to expected tpub: \(Self.convertedTpub)")
#expect(desc.contains("[d03ce438/48'/1'/0'/2']\(Self.convertedTpub)"), "Converted tpub should retain the original fingerprint/origin")
}
@Test func singleChainDescriptorNormalizesVpubToTpub() {
let external = BitcoinService.buildDescriptor(
requiredSignatures: 2,
cosigners: Self.mixedFormatCosigners,
network: .testnet4,
isChange: false
)
#expect(!external.contains("Vpub"), "External descriptor should not contain Vpub")
#expect(external.contains(Self.convertedTpub), "External descriptor should contain the converted tpub")
}
@Test func descriptorBuiltFromMixedFormatsMatchesAllTpubVersion() {
// Building the descriptor from the Vpub-mixed list should produce the same
// result as building it from the equivalent all-tpub list proving the
// SLIP132 input is fully normalized away.
let allTpubCosigners: [(xpub: String, fingerprint: String, derivationPath: String)] = [
(xpub: Self.convertedTpub, fingerprint: "d03ce438", derivationPath: "m/48'/1'/0'/2'"),
Self.mixedFormatCosigners[1],
Self.mixedFormatCosigners[2],
]
let fromMixed = BitcoinService.buildCombinedDescriptor(
requiredSignatures: 2,
cosigners: Self.mixedFormatCosigners,
network: .testnet4
)
let fromAllTpub = BitcoinService.buildCombinedDescriptor(
requiredSignatures: 2,
cosigners: allTpubCosigners,
network: .testnet4
)
#expect(fromMixed == fromAllTpub, "Descriptor built from Vpub+tpub mix should equal all-tpub descriptor")
}
@Test func descriptorSortsByNormalizedXpubForBIP67() {
// The user-supplied example: cosigners entered in [Vpub, tpub, tpub] order
// with fingerprints [d03ce438, f9755e5b, acc95047]. After normalization,
// BIP67 lexicographic sort by tpub puts them in this fingerprint order:
// 1. f9755e5b (tpubDE2JvCZ3g8tEX...)
// 2. d03ce438 (tpubDE4AYPPuhwTk7... converted from Vpub)
// 3. acc95047 (tpubDFEegnzQJr8L...)
let desc = BitcoinService.buildCombinedDescriptor(
requiredSignatures: 2,
cosigners: Self.mixedFormatCosigners,
network: .testnet4
)
let fp1 = desc.range(of: "[f9755e5b/48'/1'/0'/2']")
let fp2 = desc.range(of: "[d03ce438/48'/1'/0'/2']")
let fp3 = desc.range(of: "[acc95047/48'/1'/0'/2']")
#expect(fp1 != nil, "Descriptor should contain f9755e5b key origin")
#expect(fp2 != nil, "Descriptor should contain d03ce438 key origin")
#expect(fp3 != nil, "Descriptor should contain acc95047 key origin")
if let fp1, let fp2, let fp3 {
#expect(fp1.lowerBound < fp2.lowerBound, "f9755e5b (tpubDE2J...) should sort before d03ce438 (tpubDE4A...)")
#expect(fp2.lowerBound < fp3.lowerBound, "d03ce438 (tpubDE4A...) should sort before acc95047 (tpubDFEe...)")
}
}
/// Real descriptor decoded from a known-good crypto-output UR (from URServiceTests)
private func realURDescriptor() -> String? {
let urString = "UR:CRYPTO-OUTPUT/TAADMETAADMSOEADADAOLFTAADDLOSAOWKAXHDCLAOPDFNLNESAXHSJOFTVWFWHPTDUYPYHSROVLSWVDSRVWKBNNECZTHYMOURGSFDVDVAAAHDCXGMDKHPWMZTLRSOBSMWIOBWFWRPTODKNSEYAMTAHKRKQDISJTGWNSTSSFQDKPZSVTAHTAADEHOEADAEAOADAMTAADDYOTADLOCSDYYKADYKAEYKAOYKAOCYDYOTJEGMAXAAAYCYOYJNLKZMASJZGUIHIHIEGUINIOJTIHJPCXEYTAADDLOSAOWKAXHDCLAXIYMYFYWEMKASIOVSFYFDFDVASWONMTSKURSSTDMHVWSKLEAMKOVSGSDSCNSGNDOEAAHDCXBAMHFTFLGSDTBGBGFGGUREENGLFYTSHSCEJNKPHGGLFDFMTEWLENBDBBOXDYEMWTAHTAADEHOEADAEAOADAMTAADDYOTADLOCSDYYKADYKAEYKAOYKAOCYKNBWOSPAAXAAAYCYGRFPNSJOASJZGUIHIHIEGUINIOJTIHJPCXEHDLSWWZMD"

View File

@ -0,0 +1,28 @@
@testable import birch
import Foundation
final class MockKeychainHelper: KeychainStoring {
nonisolated(unsafe) static var store: [String: Data] = [:]
static func reset() {
store.removeAll()
}
@discardableResult
static func save(_ data: Data, forKey key: String) -> Bool {
store[key] = data
return true
}
static func load(forKey key: String) -> Data? {
store[key]
}
static func delete(forKey key: String) {
store.removeValue(forKey: key)
}
static func deleteAll() {
store.removeAll()
}
}