// // CryptoManager.swift // Montana Protocol — macOS // // High-level orchestrator for Layer 1 cryptographic operations // ML-DSA-65 key generation from BIP-39 seed, signing, address derivation // import Foundation import SwiftUI import CommonCrypto import Security import CryptoKit @MainActor class CryptoManager: ObservableObject { static let shared = CryptoManager() @Published var hasIdentity: Bool = false @Published var address: String = "" @Published var publicKeyHex: String = "" private let keychain = KeychainManager.shared private static let addressCacheKey = "montana_cached_address" /// Version header for encrypted blobs: "MT" + version (self-describing format) /// Prevents downgrade attacks — format is identified from the blob itself, not external state private static let encHeader = Data([0x4d, 0x54, 0x01]) // MT1: PBKDF2 private static let encHeaderV2 = Data([0x4d, 0x54, 0x02]) // MT2: Argon2id + optional SE init() { // Load from UserDefaults cache — NO Keychain access at startup! // This prevents the macOS Keychain password prompt on every launch. loadCachedIdentity() // PIN existence: UserDefaults is a UI hint ONLY, NOT authoritative // Security decisions use isPinActive (Keychain-based) via computed property hasPinCache = UserDefaults.standard.bool(forKey: "montana_has_pin") } // MARK: - Identity /// Load identity from UserDefaults cache (no Keychain access at startup) private func loadCachedIdentity() { if let cachedAddress = UserDefaults.standard.string(forKey: Self.addressCacheKey), !cachedAddress.isEmpty { hasIdentity = true address = cachedAddress syncAddressToEngine() } else { hasIdentity = false } } /// Cache address in UserDefaults private func cacheAddress(_ addr: String) { UserDefaults.standard.set(addr, forKey: Self.addressCacheKey) } /// Clear cached address private func clearAddressCache() { UserDefaults.standard.removeObject(forKey: Self.addressCacheKey) } /// Sync address to PresenceEngine + auto-start tracking private func syncAddressToEngine() { guard !address.isEmpty else { return } PresenceEngine.shared.address = address if !PresenceEngine.shared.isTracking { PresenceEngine.shared.autoStart() } } /// Generate new identity: BIP-39 mnemonic → PBKDF2 → ML-DSA-65 keypair /// - Returns: 24 mnemonic words (must be shown to user for backup!) func generateIdentity() -> [String]? { // Reset any previous balance — new address = zero balance PresenceEngine.shared.fullReset() // SECURITY: Clear ALL old crypto state (including PIN) before storing new plaintext keys // This prevents plaintext + active PIN coexistence keychain.deleteAll() hasPin = false // 1. Generate 24-word mnemonic guard let words = MontanaSeed.generateMnemonic() else { return nil } // 2. Derive keypair from mnemonic guard var keypair = MontanaSeed.keypairFromMnemonic(words) else { return nil } defer { MLDSA65.zeroMemory(&keypair.privateKey) } // 3. Save to Keychain (keys + mnemonic) let mnemonicData = words.joined(separator: " ").data(using: .utf8)! guard keychain.save(.privateKey, data: keypair.privateKey), keychain.save(.publicKey, data: keypair.publicKey), keychain.save(.mnemonic, data: mnemonicData) else { return nil } // 4. Update state + cache address = MLDSA65.generateAddress(from: keypair.publicKey) publicKeyHex = keypair.publicKey.map { String(format: "%02x", $0) }.joined() hasIdentity = true cacheAddress(address) syncAddressToEngine() return words } /// Recover identity from existing 24-word mnemonic /// - Parameter words: 24 BIP-39 mnemonic words /// - Returns: true if recovery succeeded func recoverIdentity(from words: [String]) -> Bool { // Reset any previous balance — recovered address syncs from server PresenceEngine.shared.fullReset() // SECURITY: Clear ALL old crypto state (including PIN) before storing new plaintext keys keychain.deleteAll() hasPin = false guard MontanaSeed.validateMnemonic(words) else { return false } guard var keypair = MontanaSeed.keypairFromMnemonic(words) else { return false } defer { MLDSA65.zeroMemory(&keypair.privateKey) } let mnemonicData = words.joined(separator: " ").data(using: .utf8)! guard keychain.save(.privateKey, data: keypair.privateKey), keychain.save(.publicKey, data: keypair.publicKey), keychain.save(.mnemonic, data: mnemonicData) else { return false } address = MLDSA65.generateAddress(from: keypair.publicKey) publicKeyHex = keypair.publicKey.map { String(format: "%02x", $0) }.joined() hasIdentity = true cacheAddress(address) syncAddressToEngine() return true } /// Delete identity from Keychain + clear cache + full balance reset func deleteIdentity() { keychain.deleteAll() clearAddressCache() hasIdentity = false hasPin = false address = "" publicKeyHex = "" UserDefaults.standard.removeObject(forKey: "montana_has_pin") // Clean up legacy UserDefaults lockout keys UserDefaults.standard.removeObject(forKey: "montana_pin_fails") UserDefaults.standard.removeObject(forKey: "montana_pin_lockout_end") UserDefaults.standard.removeObject(forKey: "montana_pin_lockout_level") PresenceEngine.shared.fullReset() } // MARK: - AES-256 Encryption / Key Derivation /// [LEGACY] Derive AES-256 key from PIN using PBKDF2 (600k iterations) — MT1 only private func deriveAESKeyPBKDF2(from pin: String, salt: Data) -> Data? { guard let pinData = pin.data(using: .utf8) else { return nil } var derivedKey = [UInt8](repeating: 0, count: 32) let status = pinData.withUnsafeBytes { pinPtr in salt.withUnsafeBytes { saltPtr in CCKeyDerivationPBKDF( CCPBKDFAlgorithm(kCCPBKDF2), pinPtr.baseAddress?.assumingMemoryBound(to: Int8.self), pinData.count, saltPtr.baseAddress?.assumingMemoryBound(to: UInt8.self), salt.count, CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256), 600_000, &derivedKey, 32 ) } } guard status == kCCSuccess else { return nil } return Data(derivedKey) } /// [NEW] Derive 32-byte key from PIN using Argon2id (64MB, 3 iter, 4 lanes) /// GPU/ASIC-resistant KDF — replaces PBKDF2 for MT2 blobs private func deriveAESKeyArgon2id(from pin: String, salt: Data) -> Data? { guard let pinData = pin.data(using: .utf8), !pinData.isEmpty else { return nil } guard salt.count == 16 else { return nil } var derivedKey = [UInt8](repeating: 0, count: 32) let result = pinData.withUnsafeBytes { pinPtr in salt.withUnsafeBytes { saltPtr in argon2id_hash_raw( 3, // t_cost: 3 iterations 65536, // m_cost: 64 MiB (in KiB) 4, // parallelism: 4 threads pinPtr.baseAddress, // password pinData.count, // password length saltPtr.baseAddress, // salt salt.count, // salt length &derivedKey, // output hash 32 // hash length ) } } guard result == 0 else { return nil } // ARGON2_OK = 0 return Data(derivedKey) } /// Derive PIN verification hash via Argon2id + HKDF (domain-separated from encryption key) /// SECURITY: This hash is stored in Keychain for verification. /// The encryption key uses a DIFFERENT HKDF domain ("montana-pin-encrypt-v2"). /// Even if this hash leaks, encryption key cannot be derived from it. private func hashPinArgon2id(from pin: String, salt: Data) -> Data? { guard let raw = deriveAESKeyArgon2id(from: pin, salt: salt) else { return nil } let derived = HKDF.deriveKey( inputKeyMaterial: SymmetricKey(data: raw), salt: Data(), info: "montana-pin-verify-v2".data(using: .utf8)!, outputByteCount: 32 ) return derived.withUnsafeBytes { Data($0) } } // MARK: - Secure Enclave (Optional Hardware Protection) /// Check if Secure Enclave is available on this Mac (Apple Silicon / T2) private var isSecureEnclaveAvailable: Bool { SecureEnclave.isAvailable } /// Check if SE was used for the current wallet (Keychain flag) var isSecureEnclaveEnabled: Bool { guard let flag = keychain.load(.seEnabled) else { return false } return flag.count == 1 && flag[0] == 0x01 } /// Create SE P-256 key, generate random 32-byte wrap key, encrypt with ECDH+AES-GCM. /// Returns cleartext wrap key (for immediate use) or nil if SE unavailable. private func seCreateAndWrapKey() -> Data? { guard isSecureEnclaveAvailable else { return nil } do { // 1. Create persistent SE key (P-256 KeyAgreement) let seKey = try SecureEnclave.P256.KeyAgreement.PrivateKey() // 2. Generate random 32-byte wrap key var wrapKeyBytes = [UInt8](repeating: 0, count: 32) guard SecRandomCopyBytes(kSecRandomDefault, 32, &wrapKeyBytes) == errSecSuccess else { return nil } let wrapKey = Data(wrapKeyBytes) // 3. Create companion key for ECDH let companion = P256.KeyAgreement.PrivateKey() let sharedSecret = try companion.sharedSecretFromKeyAgreement( with: seKey.publicKey ) // 4. Derive symmetric key via HKDF let symmetricKey = sharedSecret.hkdfDerivedSymmetricKey( using: SHA256.self, salt: "montana-se-wrap-v1".data(using: .utf8)!, sharedInfo: Data(), outputByteCount: 32 ) // 5. AES-GCM seal wrap key let sealedBox = try AES.GCM.seal(wrapKey, using: symmetricKey) guard let sealedData = sealedBox.combined else { return nil } // 6. Build envelope: [1 byte pubLen] + companion public key + sealed box let companionPubData = companion.publicKey.x963Representation var envelope = Data() envelope.append(UInt8(companionPubData.count)) envelope.append(companionPubData) envelope.append(sealedData) // 7. Store SE key reference + envelope in Keychain keychain.save(.seKeyTag, data: seKey.dataRepresentation) keychain.save(.seWrappedKey, data: envelope) keychain.save(.seEnabled, data: Data([0x01])) return wrapKey } catch { // SE creation failed (ad-hoc signing, etc.) — graceful fallback return nil } } /// Unwrap the 32-byte key using Secure Enclave private func seUnwrapKey() -> Data? { guard isSecureEnclaveEnabled else { return nil } guard let seKeyData = keychain.load(.seKeyTag), let envelopeData = keychain.load(.seWrappedKey) else { return nil } do { // 1. Parse envelope: [1 byte pubLen] + companion public + sealed box guard envelopeData.count > 1 else { return nil } let pubLen = Int(envelopeData[0]) guard envelopeData.count >= 1 + pubLen + 12 + 32 + 16 else { return nil } let companionPubData = envelopeData.subdata(in: 1..<(1 + pubLen)) let sealedData = envelopeData.subdata(in: (1 + pubLen).. Data? { guard let raw = deriveAESKeyArgon2id(from: pin, salt: salt) else { return nil } // Domain separation: encryption key ≠ verification hash let encMaterial = HKDF.deriveKey( inputKeyMaterial: SymmetricKey(data: raw), salt: Data(), info: "montana-pin-encrypt-v2".data(using: .utf8)!, outputByteCount: 32 ) let pinKey = encMaterial.withUnsafeBytes { Data($0) } let seRequired = useSE ?? isSecureEnclaveEnabled if seRequired, let seKey = seUnwrapKey() { // Two-factor: PIN + hardware let derived = HKDF.deriveKey( inputKeyMaterial: SymmetricKey(data: pinKey), salt: seKey, info: "montana-enc-v2".data(using: .utf8)!, outputByteCount: 32 ) return derived.withUnsafeBytes { Data($0) } } else if seRequired { // SE was required but unavailable — FAIL (keys unrecoverable without SE) return nil } else { // PIN-only (no SE) let derived = HKDF.deriveKey( inputKeyMaterial: SymmetricKey(data: pinKey), salt: Data(), info: "montana-enc-v2-no-se".data(using: .utf8)!, outputByteCount: 32 ) return derived.withUnsafeBytes { Data($0) } } } /// MAC key derivation for MT2 (different domain from MT1) private func deriveMACKeyV2(from encKey: Data) -> Data { let info = "montana-hmac-key-v2".data(using: .utf8)! var mac = [UInt8](repeating: 0, count: 32) encKey.withUnsafeBytes { keyPtr in info.withUnsafeBytes { infoPtr in CCHmac( CCHmacAlgorithm(kCCHmacAlgSHA256), keyPtr.baseAddress, encKey.count, infoPtr.baseAddress, info.count, &mac ) } } return Data(mac) } /// Derive MAC key from encryption key (domain separation for Encrypt-then-MAC) — MT1 private func deriveMACKey(from encKey: Data) -> Data { let info = "montana-hmac-key".data(using: .utf8)! var mac = [UInt8](repeating: 0, count: 32) encKey.withUnsafeBytes { keyPtr in info.withUnsafeBytes { infoPtr in CCHmac( CCHmacAlgorithm(kCCHmacAlgSHA256), keyPtr.baseAddress, encKey.count, infoPtr.baseAddress, info.count, &mac ) } } return Data(mac) } /// AES-256-CBC + HMAC-SHA256 encrypt (auto-selects MT2 or MT1 based on available salt) private func encryptAES256(_ plaintext: Data, pin: String) -> Data? { // Prefer MT2 (Argon2id) if argon2Salt exists if let argon2Salt = keychain.load(.argon2Salt) { return encryptMT2(plaintext, pin: pin, salt: argon2Salt) } // Fallback to MT1 (PBKDF2) for legacy guard let salt = keychain.load(.pinSalt), let key = deriveAESKeyPBKDF2(from: pin, salt: salt) else { return nil } return encryptWithKey(plaintext, key: key) } /// MT2 encrypt: header(3) + flags(1) + IV(16) + ciphertext + HMAC(32) private func encryptMT2(_ plaintext: Data, pin: String, salt: Data) -> Data? { guard let key = deriveFinalKeyV2(from: pin, salt: salt) else { return nil } let macKey = deriveMACKeyV2(from: key) var iv = [UInt8](repeating: 0, count: kCCBlockSizeAES128) guard SecRandomCopyBytes(kSecRandomDefault, iv.count, &iv) == errSecSuccess else { return nil } let bufferSize = plaintext.count + kCCBlockSizeAES128 var buffer = [UInt8](repeating: 0, count: bufferSize) var numBytesEncrypted = 0 let status = key.withUnsafeBytes { keyPtr in plaintext.withUnsafeBytes { dataPtr in CCCrypt( CCOperation(kCCEncrypt), CCAlgorithm(kCCAlgorithmAES), CCOptions(kCCOptionPKCS7Padding), keyPtr.baseAddress, kCCKeySizeAES256, iv, dataPtr.baseAddress, plaintext.count, &buffer, bufferSize, &numBytesEncrypted ) } } guard status == kCCSuccess else { return nil } // Flags: bit 0 = SE used let flags: UInt8 = isSecureEnclaveEnabled ? 0x01 : 0x00 let authenticated = Data([flags]) + Data(iv) + Data(buffer.prefix(numBytesEncrypted)) // HMAC over (flags + IV + ciphertext) var hmac = [UInt8](repeating: 0, count: 32) macKey.withUnsafeBytes { macPtr in authenticated.withUnsafeBytes { dataPtr in CCHmac( CCHmacAlgorithm(kCCHmacAlgSHA256), macPtr.baseAddress, macKey.count, dataPtr.baseAddress, authenticated.count, &hmac ) } } return Self.encHeaderV2 + authenticated + Data(hmac) } /// Self-describing decrypt: MT2 → Argon2id, MT1 → PBKDF2, no header → legacy CBC /// Format decision based on BLOB HEADER, not external state private func decryptAES256(_ encrypted: Data, pin: String) -> Data? { guard encrypted.count > 3 else { return nil } if encrypted.prefix(3) == Self.encHeaderV2 { // MT2: Argon2id + optional SE return decryptMT2(encrypted, pin: pin) } if encrypted.prefix(3) == Self.encHeader { // MT1: PBKDF2 + HMAC guard let salt = keychain.load(.pinSalt) else { return nil } guard let key = deriveAESKeyPBKDF2(from: pin, salt: salt) else { return nil } let body = encrypted.dropFirst(3) return decryptWithHMAC(Data(body), key: key) } // Legacy: no header → pre-v3.10 CBC-only guard keychain.load(.pinSalt) == nil && keychain.load(.argon2Salt) == nil else { return nil } let legacySalt = "montana_seed_aes256_v1".data(using: .utf8)! guard let key = deriveAESKeyPBKDF2(from: pin, salt: legacySalt) else { return nil } return decryptCBC(encrypted, key: key) } /// Decrypt MT2 blob: header(3) + flags(1) + IV(16) + ciphertext + HMAC(32) /// SECURITY: Uses blob flags (not Keychain state) to determine SE requirement private func decryptMT2(_ encrypted: Data, pin: String) -> Data? { // Minimum: header(3) + flags(1) + IV(16) + 1block(16) + HMAC(32) = 68 guard encrypted.count >= 68, encrypted.prefix(3) == Self.encHeaderV2 else { return nil } // Extract SE flag from blob (byte 3 after header) — authoritative for THIS blob let body = encrypted.dropFirst(3) let blobUsedSE = (body[body.startIndex] & 0x01) != 0 guard let salt = keychain.load(.argon2Salt), let key = deriveFinalKeyV2(from: pin, salt: salt, useSE: blobUsedSE) else { return nil } let macKey = deriveMACKeyV2(from: key) let dataForMAC = body.prefix(body.count - 32) let storedHMAC = body.suffix(32) // Compute + verify HMAC (constant-time) var computedHMAC = [UInt8](repeating: 0, count: 32) macKey.withUnsafeBytes { macPtr in dataForMAC.withUnsafeBytes { dataPtr in CCHmac( CCHmacAlgorithm(kCCHmacAlgSHA256), macPtr.baseAddress, macKey.count, dataPtr.baseAddress, dataForMAC.count, &computedHMAC ) } } var diff: UInt8 = 0 let hmacBytes = Array(storedHMAC) for i in 0..<32 { diff |= computedHMAC[i] ^ hmacBytes[i] } guard diff == 0 else { return nil } // Skip flags byte (1), then IV + ciphertext let ivAndCiphertext = Data(dataForMAC.dropFirst(1)) return decryptCBC(ivAndCiphertext, key: key) } /// Encrypt-then-MAC: AES-256-CBC + HMAC-SHA256 /// Format: IV (16) + ciphertext + HMAC-SHA256(IV+ciphertext) (32) private func encryptWithKey(_ plaintext: Data, key: Data) -> Data? { let macKey = deriveMACKey(from: key) var iv = [UInt8](repeating: 0, count: kCCBlockSizeAES128) guard SecRandomCopyBytes(kSecRandomDefault, iv.count, &iv) == errSecSuccess else { return nil } let bufferSize = plaintext.count + kCCBlockSizeAES128 var buffer = [UInt8](repeating: 0, count: bufferSize) var numBytesEncrypted = 0 let status = key.withUnsafeBytes { keyPtr in plaintext.withUnsafeBytes { dataPtr in CCCrypt( CCOperation(kCCEncrypt), CCAlgorithm(kCCAlgorithmAES), CCOptions(kCCOptionPKCS7Padding), keyPtr.baseAddress, kCCKeySizeAES256, iv, dataPtr.baseAddress, plaintext.count, &buffer, bufferSize, &numBytesEncrypted ) } } guard status == kCCSuccess else { return nil } let ivPlusCiphertext = Data(iv) + Data(buffer.prefix(numBytesEncrypted)) // Encrypt-then-MAC: HMAC over (IV + ciphertext) var hmac = [UInt8](repeating: 0, count: 32) macKey.withUnsafeBytes { macPtr in ivPlusCiphertext.withUnsafeBytes { dataPtr in CCHmac( CCHmacAlgorithm(kCCHmacAlgSHA256), macPtr.baseAddress, macKey.count, dataPtr.baseAddress, ivPlusCiphertext.count, &hmac ) } } return Self.encHeader + ivPlusCiphertext + Data(hmac) } /// Strict HMAC-authenticated decrypt: returns nil if HMAC invalid (no fallback) /// Format: IV (16) + ciphertext + HMAC-SHA256(IV+ciphertext) (32) private func decryptWithHMAC(_ encrypted: Data, key: Data) -> Data? { guard encrypted.count > kCCBlockSizeAES128 + 32 else { return nil } let macKey = deriveMACKey(from: key) let dataForMAC = encrypted.prefix(encrypted.count - 32) let storedHMAC = encrypted.suffix(32) // Compute expected HMAC var computedHMAC = [UInt8](repeating: 0, count: 32) macKey.withUnsafeBytes { macPtr in dataForMAC.withUnsafeBytes { dataPtr in CCHmac( CCHmacAlgorithm(kCCHmacAlgSHA256), macPtr.baseAddress, macKey.count, dataPtr.baseAddress, dataForMAC.count, &computedHMAC ) } } // Constant-time HMAC comparison — reject if tampered var diff: UInt8 = 0 let hmacBytes = Array(storedHMAC) for i in 0..<32 { diff |= computedHMAC[i] ^ hmacBytes[i] } guard diff == 0 else { return nil } return decryptCBC(dataForMAC, key: key) } /// Raw AES-256-CBC decryption (internal, used by decryptWithKey) private func decryptCBC(_ encrypted: Data, key: Data) -> Data? { guard encrypted.count > kCCBlockSizeAES128 else { return nil } let iv = Array(encrypted.prefix(kCCBlockSizeAES128)) let ciphertext = encrypted.suffix(from: kCCBlockSizeAES128) let bufferSize = ciphertext.count + kCCBlockSizeAES128 var buffer = [UInt8](repeating: 0, count: bufferSize) var numBytesDecrypted = 0 let status = key.withUnsafeBytes { keyPtr in ciphertext.withUnsafeBytes { dataPtr in CCCrypt( CCOperation(kCCDecrypt), CCAlgorithm(kCCAlgorithmAES), CCOptions(kCCOptionPKCS7Padding), keyPtr.baseAddress, kCCKeySizeAES256, iv, dataPtr.baseAddress, ciphertext.count, &buffer, bufferSize, &numBytesDecrypted ) } } guard status == kCCSuccess else { return nil } return Data(buffer.prefix(numBytesDecrypted)) } // MARK: - Mnemonic /// Load mnemonic decrypted with PIN (lockout enforced) func loadMnemonic(pin: String) -> [String]? { // SECURITY: Enforce lockout — prevents brute-force via mnemonic decryption guard verifyPin(pin) else { return nil } guard let encrypted = keychain.load(.mnemonicEncrypted), let decrypted = decryptAES256(encrypted, pin: pin), let phrase = String(data: decrypted, encoding: .utf8) else { return nil } let words = phrase.split(separator: " ").map(String.init) return words.count == 24 ? words : nil } /// Check if plaintext (unencrypted) keys exist — used for migration detection var hasPlaintextKeys: Bool { keychain.load(.privateKey) != nil || keychain.load(.mnemonic) != nil } // MARK: - PIN /// UI cache only — NOT used for security decisions @Published var hasPinCache: Bool = false /// Published wrapper: use hasPinCache for UI, isPinActive for security var hasPin: Bool { get { hasPinCache } set { hasPinCache = newValue UserDefaults.standard.set(newValue, forKey: "montana_has_pin") } } /// AUTHORITATIVE PIN check: Keychain-backed (tamper-resistant) /// All security decisions MUST use this, never UserDefaults var isPinActive: Bool { keychain.load(.pinHash) != nil && (keychain.load(.argon2Salt) != nil || keychain.load(.pinSalt) != nil) } /// Check if wallet has MT1 (PBKDF2) blobs that need migration to MT2 (Argon2id) var needsMT2Migration: Bool { keychain.load(.pinHash) != nil && keychain.load(.pinSalt) != nil && keychain.load(.argon2Salt) == nil } // Lockout: 3 wrong → 1min, next → 10min, next → 1hr, next → 24hr private static let lockoutDurations: [TimeInterval] = [60, 600, 3600, 86400] /// Generate random 16-byte salt and save to Keychain private func generateNewSalt() -> Data? { var salt = [UInt8](repeating: 0, count: 16) guard SecRandomCopyBytes(kSecRandomDefault, salt.count, &salt) == errSecSuccess else { return nil } let saltData = Data(salt) keychain.save(.pinSalt, data: saltData) return saltData } /// Hash PIN with PBKDF2-HMAC-SHA256 (600k iterations, with random salt) private func hashPin(_ pin: String, salt: Data) -> Data? { guard let pinData = pin.data(using: .utf8) else { return nil } var hash = [UInt8](repeating: 0, count: 32) let status = pinData.withUnsafeBytes { pinPtr in salt.withUnsafeBytes { saltPtr in CCKeyDerivationPBKDF( CCPBKDFAlgorithm(kCCPBKDF2), pinPtr.baseAddress?.assumingMemoryBound(to: Int8.self), pinData.count, saltPtr.baseAddress?.assumingMemoryBound(to: UInt8.self), salt.count, CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256), 600_000, &hash, 32 ) } } guard status == kCCSuccess else { return nil } return Data(hash) } /// Legacy PIN hash: SHA-256 (no salt) — backward compatibility only private func hashPinLegacy(_ pin: String) -> Data? { guard let data = pin.data(using: .utf8) else { return nil } var hash = [UInt8](repeating: 0, count: 32) data.withUnsafeBytes { ptr in _ = CC_SHA256(ptr.baseAddress, CC_LONG(data.count), &hash) } return Data(hash) } // MARK: - Lockout (Keychain-backed, tamper-resistant) private func saveLockoutInt(_ key: KeychainManager.Key, _ value: Int) { var v = value let data = Data(bytes: &v, count: MemoryLayout.size) keychain.save(key, data: data) } private func loadLockoutInt(_ key: KeychainManager.Key) -> Int { guard let data = keychain.load(key), data.count == MemoryLayout.size else { return 0 } return data.withUnsafeBytes { $0.load(as: Int.self) } } private func saveLockoutDouble(_ key: KeychainManager.Key, _ value: Double) { var v = value let data = Data(bytes: &v, count: MemoryLayout.size) keychain.save(key, data: data) } private func loadLockoutDouble(_ key: KeychainManager.Key) -> Double { guard let data = keychain.load(key), data.count == MemoryLayout.size else { return 0.0 } return data.withUnsafeBytes { $0.load(as: Double.self) } } var failedAttempts: Int { loadLockoutInt(.lockoutFails) } var lockoutEnd: Date? { let ts = loadLockoutDouble(.lockoutEnd) guard ts > 0 else { return nil } let date = Date(timeIntervalSince1970: ts) return date > Date() ? date : nil } var isLockedOut: Bool { lockoutEnd != nil } var lockoutSecondsLeft: Int { guard let end = lockoutEnd else { return 0 } return max(Int(end.timeIntervalSinceNow), 0) } private func registerFailedAttempt() { let fails = loadLockoutInt(.lockoutFails) + 1 saveLockoutInt(.lockoutFails, fails) if fails >= 3 { let level = loadLockoutInt(.lockoutLevel) let idx = min(level, Self.lockoutDurations.count - 1) let duration = Self.lockoutDurations[idx] let end = Date().addingTimeInterval(duration) saveLockoutDouble(.lockoutEnd, end.timeIntervalSince1970) saveLockoutInt(.lockoutLevel, level + 1) saveLockoutInt(.lockoutFails, 0) } } /// Reset consecutive failure counter only (on successful PIN entry) /// Does NOT reset escalation level — prevents lockout bypass via interleaved success private func resetConsecutiveFails() { keychain.delete(.lockoutFails) keychain.delete(.lockoutEnd) } /// Full lockout reset including escalation (only on PIN creation/change/delete) private func resetLockout() { keychain.delete(.lockoutFails) keychain.delete(.lockoutEnd) keychain.delete(.lockoutLevel) } /// Create new PIN (8 digits) + encrypt with Argon2id + optional SE /// ATOMIC: hasPin only set true after ALL encrypted saves confirmed func createPin(_ pin: String) -> Bool { guard pin.count == 8, pin.allSatisfy({ $0.isNumber }) else { return false } // Phase 1: Generate Argon2id salt (16 bytes) var saltBytes = [UInt8](repeating: 0, count: 16) guard SecRandomCopyBytes(kSecRandomDefault, 16, &saltBytes) == errSecSuccess else { return false } let argon2Salt = Data(saltBytes) guard keychain.save(.argon2Salt, data: argon2Salt) else { return false } // Clean stale SE flags before setup (prevents stale state from previous wallet) keychain.delete(.seEnabled); keychain.delete(.seKeyTag); keychain.delete(.seWrappedKey) // Try Secure Enclave setup (best-effort, nil = SE unavailable) let _ = seCreateAndWrapKey() // Hash PIN for verification (domain-separated from encryption key) guard let hash = hashPinArgon2id(from: pin, salt: argon2Salt) else { keychain.delete(.argon2Salt) return false } // Phase 2: Encrypt all plaintext material with MT2 var encryptedPrivKey: Data? var encryptedMnemonic: Data? if let privKey = keychain.load(.privateKey) { guard let encrypted = encryptMT2(privKey, pin: pin, salt: argon2Salt) else { keychain.delete(.argon2Salt) keychain.delete(.seEnabled); keychain.delete(.seKeyTag); keychain.delete(.seWrappedKey) return false } encryptedPrivKey = encrypted } if let mnemonic = keychain.load(.mnemonic) { guard let encrypted = encryptMT2(mnemonic, pin: pin, salt: argon2Salt) else { keychain.delete(.argon2Salt) keychain.delete(.seEnabled); keychain.delete(.seKeyTag); keychain.delete(.seWrappedKey) return false } encryptedMnemonic = encrypted } // Phase 3: Save pin hash guard keychain.save(.pinHash, data: hash) else { keychain.delete(.argon2Salt) keychain.delete(.seEnabled); keychain.delete(.seKeyTag); keychain.delete(.seWrappedKey) return false } // Phase 4: Save ALL encrypted items (all-or-nothing) var allSaved = true if let enc = encryptedPrivKey { allSaved = allSaved && keychain.save(.privateKeyEncrypted, data: enc) } if let enc = encryptedMnemonic { allSaved = allSaved && keychain.save(.mnemonicEncrypted, data: enc) } guard allSaved else { // Rollback: delete everything, keep plaintext intact keychain.delete(.pinHash) keychain.delete(.argon2Salt) keychain.delete(.privateKeyEncrypted) keychain.delete(.mnemonicEncrypted) keychain.delete(.seEnabled); keychain.delete(.seKeyTag); keychain.delete(.seWrappedKey) return false } // Phase 5: Delete plaintext + old PBKDF2 salt (encrypted copies confirmed) keychain.delete(.privateKey) keychain.delete(.mnemonic) keychain.delete(.pinSalt) // Remove old PBKDF2 salt if migrating // Phase 6: Mark PIN as active hasPin = true resetLockout() return true } /// Change PIN: verify old → decrypt → re-encrypt with Argon2id MT2 → commit atomically func changePin(old: String, new: String) -> Bool { guard verifyPin(old) else { return false } guard new.count == 8, new.allSatisfy({ $0.isNumber }) else { return false } // Phase 1: Decrypt all material with old PIN (auto-detects MT2/MT1/legacy) var decryptedPrivKey: Data? if let encrypted = keychain.load(.privateKeyEncrypted) { guard let decrypted = decryptAES256(encrypted, pin: old) else { return false } decryptedPrivKey = decrypted } var decryptedMnemonic: Data? if let encrypted = keychain.load(.mnemonicEncrypted) { guard let decrypted = decryptAES256(encrypted, pin: old) else { return false } decryptedMnemonic = decrypted } defer { if var dk = decryptedPrivKey { MLDSA65.zeroMemory(&dk); decryptedPrivKey = nil } if var dm = decryptedMnemonic { MLDSA65.zeroMemory(&dm); decryptedMnemonic = nil } } // Phase 2: Generate new Argon2id salt + hash IN MEMORY (NOT saved to Keychain yet) // SECURITY: Salt is passed as parameter to encryptMT2, not read from Keychain. // This prevents crash-between-steps from bricking the wallet. var newSaltBytes = [UInt8](repeating: 0, count: 16) guard SecRandomCopyBytes(kSecRandomDefault, 16, &newSaltBytes) == errSecSuccess else { return false } let newSalt = Data(newSaltBytes) // Hash new PIN with Argon2id (domain-separated from encryption key) guard let newHash = hashPinArgon2id(from: new, salt: newSalt) else { return false } // Phase 3: Re-encrypt with new PIN as MT2 (salt passed as param, NOT from Keychain) var reEncPrivKey: Data? if let privKey = decryptedPrivKey { guard let enc = encryptMT2(privKey, pin: new, salt: newSalt) else { return false } reEncPrivKey = enc } var reEncMnemonic: Data? if let mnemonic = decryptedMnemonic { guard let enc = encryptMT2(mnemonic, pin: new, salt: newSalt) else { return false } reEncMnemonic = enc } // Phase 4: Save encrypted blobs var ok = true if let enc = reEncPrivKey { ok = ok && keychain.save(.privateKeyEncrypted, data: enc) } if let enc = reEncMnemonic { ok = ok && keychain.save(.mnemonicEncrypted, data: enc) } guard ok else { return false } // Phase 5: COMMIT — save salt + hash + cleanup (atomic point) // Only now do we modify Keychain state — all encrypted blobs already saved keychain.save(.argon2Salt, data: newSalt) keychain.save(.pinHash, data: newHash) keychain.delete(.pinSalt) // Remove old PBKDF2 salt (fully migrated to Argon2id) hasPin = true resetLockout() return true } /// Delete PIN + delete all encrypted data (keys & seed become unrecoverable) func deletePin() { keychain.delete(.pinHash) keychain.delete(.pinSalt) keychain.delete(.argon2Salt) keychain.delete(.privateKeyEncrypted) keychain.delete(.mnemonicEncrypted) // Clean up SE keys keychain.delete(.seEnabled) keychain.delete(.seKeyTag) keychain.delete(.seWrappedKey) hasPin = false resetLockout() } /// Constant-time comparison (prevents timing attacks) private func constantTimeEqual(_ a: Data, _ b: Data) -> Bool { guard a.count == b.count else { return false } var result: UInt8 = 0 for i in 0.. Bool { guard !isLockedOut else { return false } guard let stored = keychain.load(.pinHash) else { return false } let input: Data? if let argon2Salt = keychain.load(.argon2Salt) { // v2: Argon2id + HKDF domain separation (64MB, 3 iter, 4 lanes) input = hashPinArgon2id(from: pin, salt: argon2Salt) } else if let pbkdf2Salt = keychain.load(.pinSalt) { // v1: PBKDF2-HMAC-SHA256 (600k iterations) input = hashPin(pin, salt: pbkdf2Salt) } else { // Legacy: SHA-256 (no salt) input = hashPinLegacy(pin) } guard let input = input else { return false } if constantTimeEqual(stored, input) { resetConsecutiveFails() return true } else { registerFailedAttempt() return false } } // MARK: - Signing /// Sign a message (requires PIN verification + lockout enforcement) func signMessage(_ message: Data, pin: String) -> Data? { // SECURITY: Enforce lockout for signing — prevents PIN brute-force via sign API guard verifyPin(pin) else { return nil } guard var privateKey = loadPrivateKey(pin: pin) else { return nil } defer { MLDSA65.zeroMemory(&privateKey) } return MLDSA65.sign(message: message, privateKey: privateKey) } /// Sign a string message (convenience, requires PIN + lockout) func signString(_ message: String, pin: String) -> Data? { guard let data = message.data(using: .utf8) else { return nil } return signMessage(data, pin: pin) } /// Load private key decrypted with PIN /// No plaintext fallback when PIN is active (Keychain-authoritative check) private func loadPrivateKey(pin: String) -> Data? { // Try encrypted first (PIN-protected) if let encrypted = keychain.load(.privateKeyEncrypted) { return decryptAES256(encrypted, pin: pin) } // Plaintext ONLY if no PIN exists (Keychain-authoritative, not UserDefaults) guard !isPinActive else { return nil } return keychain.load(.privateKey) } // MARK: - Verification /// Verify a signature against a public key func verifySignature(message: Data, signature: Data, publicKey: Data) -> Bool { return MLDSA65.verify(message: message, signature: signature, publicKey: publicKey) } /// Validate a Montana address (format + checksum) func validateAddress(_ address: String) -> Bool { return MLDSA65.validateAddress(address) } // MARK: - Migration (MT1 → MT2) /// Migrate from MT1 (6-digit, PBKDF2) to MT2 (8-digit, Argon2id + optional SE) /// Called after user enters old 6-digit PIN + new 8-digit PIN /// SECURITY: Plaintext keys NEVER touch Keychain — decrypt in memory, re-encrypt directly func migrateMT1toMT2(oldPin: String, newPin: String) -> Bool { guard verifyPin(oldPin) else { return false } guard newPin.count == 8, newPin.allSatisfy({ $0.isNumber }) else { return false } // Phase 1: Decrypt with old PIN (MT1/legacy format) — keys stay in MEMORY only var decryptedPrivKey: Data? if let encrypted = keychain.load(.privateKeyEncrypted) { guard let decrypted = decryptAES256(encrypted, pin: oldPin) else { return false } decryptedPrivKey = decrypted } var decryptedMnemonic: Data? if let encrypted = keychain.load(.mnemonicEncrypted) { guard let decrypted = decryptAES256(encrypted, pin: oldPin) else { return false } decryptedMnemonic = decrypted } defer { if var dk = decryptedPrivKey { MLDSA65.zeroMemory(&dk) } if var dm = decryptedMnemonic { MLDSA65.zeroMemory(&dm) } } // Phase 2: Generate new Argon2id salt + SE setup var saltBytes = [UInt8](repeating: 0, count: 16) guard SecRandomCopyBytes(kSecRandomDefault, 16, &saltBytes) == errSecSuccess else { return false } let argon2Salt = Data(saltBytes) // Clean stale SE flags + setup SE (best-effort) keychain.delete(.seEnabled); keychain.delete(.seKeyTag); keychain.delete(.seWrappedKey) let _ = seCreateAndWrapKey() // Hash new PIN (domain-separated) guard let hash = hashPinArgon2id(from: newPin, salt: argon2Salt) else { keychain.delete(.seEnabled); keychain.delete(.seKeyTag); keychain.delete(.seWrappedKey) return false } // Phase 3: Re-encrypt directly with new PIN as MT2 (no plaintext in Keychain!) var encPrivKey: Data? if let privKey = decryptedPrivKey { guard let enc = encryptMT2(privKey, pin: newPin, salt: argon2Salt) else { keychain.delete(.seEnabled); keychain.delete(.seKeyTag); keychain.delete(.seWrappedKey) return false } encPrivKey = enc } var encMnemonic: Data? if let mnemonic = decryptedMnemonic { guard let enc = encryptMT2(mnemonic, pin: newPin, salt: argon2Salt) else { keychain.delete(.seEnabled); keychain.delete(.seKeyTag); keychain.delete(.seWrappedKey) return false } encMnemonic = enc } // Phase 4: Save new encrypted blobs (old blobs still intact as backup) var ok = true if let enc = encPrivKey { ok = ok && keychain.save(.privateKeyEncrypted, data: enc) } if let enc = encMnemonic { ok = ok && keychain.save(.mnemonicEncrypted, data: enc) } guard ok else { return false } // Phase 5: COMMIT — save salt + hash, delete old state // At this point: new encrypted blobs saved, old salt/hash still intact // If crash here: old PIN still works (old salt/hash present, blobs overwritten with MT2 // which will fail to decrypt with old PIN → user must recover from seed) keychain.save(.argon2Salt, data: argon2Salt) keychain.save(.pinHash, data: hash) keychain.delete(.pinSalt) keychain.delete(.privateKey) // Delete any plaintext leftovers keychain.delete(.mnemonic) hasPin = true resetLockout() return true } }