Compare commits
3 Commits
descriptor
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84c02cc6dd | ||
|
|
fac6560531 | ||
|
|
33b7e491c2 |
@ -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;
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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)))"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)))"
|
||||
|
||||
388
birchTests/AppLockViewModelTests.swift
Normal file
388
birchTests/AppLockViewModelTests.swift
Normal 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 == "")
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
|
||||
28
birchTests/Mocks/MockKeychainHelper.swift
Normal file
28
birchTests/Mocks/MockKeychainHelper.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user