montana/macOS/MontanaPresence/CryptoManager.swift

1158 lines
45 KiB
Swift

//
// 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<SHA256>.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)..<envelopeData.count)
// 2. Reconstruct SE key from stored reference
let seKey = try SecureEnclave.P256.KeyAgreement.PrivateKey(
dataRepresentation: seKeyData
)
// 3. Reconstruct companion public key
let companionPub = try P256.KeyAgreement.PublicKey(
x963Representation: companionPubData
)
// 4. ECDH: SE private + companion public shared secret
let sharedSecret = try seKey.sharedSecretFromKeyAgreement(with: companionPub)
// 5. HKDF symmetric key (same parameters as wrap time)
let symmetricKey = sharedSecret.hkdfDerivedSymmetricKey(
using: SHA256.self,
salt: "montana-se-wrap-v1".data(using: .utf8)!,
sharedInfo: Data(),
outputByteCount: 32
)
// 6. Unseal wrap key
let sealedBox = try AES.GCM.SealedBox(combined: sealedData)
let wrapKey = try AES.GCM.open(sealedBox, using: symmetricKey)
return wrapKey
} catch {
return nil
}
}
// MARK: - Combined Key Derivation v2 (Argon2id + optional SE)
/// Derive final encryption key: Argon2id(PIN) [+ SE unwrap key via HKDF]
/// - useSE: nil = auto-detect from Keychain, true/false = override (from blob flags on decrypt)
/// SECURITY: If SE was used for encryption but unavailable now FAIL (no silent downgrade)
private func deriveFinalKeyV2(from pin: String, salt: Data, useSE: Bool? = nil) -> Data? {
guard let raw = deriveAESKeyArgon2id(from: pin, salt: salt) else { return nil }
// Domain separation: encryption key verification hash
let encMaterial = HKDF<SHA256>.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<SHA256>.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<SHA256>.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<Int>.size)
keychain.save(key, data: data)
}
private func loadLockoutInt(_ key: KeychainManager.Key) -> Int {
guard let data = keychain.load(key), data.count == MemoryLayout<Int>.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<Double>.size)
keychain.save(key, data: data)
}
private func loadLockoutDouble(_ key: KeychainManager.Key) -> Double {
guard let data = keychain.load(key), data.count == MemoryLayout<Double>.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..<a.count {
result |= a[i] ^ b[i]
}
return result == 0
}
/// Verify PIN with lockout (timing-safe)
/// Supports: Argon2id (v2), PBKDF2 (v1), SHA-256 (legacy)
func verifyPin(_ pin: String) -> 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
}
}