montana/iOS/Apps/Montana/Montana/Auth/AuthService.swift

1443 lines
57 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// AuthService.swift
// Junona Montana Messenger
//
// Сервис авторизации через Montana Backend
// Постквантовая криптография ML-DSA-65
//
import Foundation
import SwiftUI
import CommonCrypto
// MARK: - Auth State
enum AuthState: Equatable {
case unauthorized
case sendingCode
case waitingForCode(phone: String)
case verifying(phone: String)
case creatingKeys
case authorized
case enteringCognitiveKey // Ввод когнитивного ключа
case restoringIdentity // Восстановление по когнитивному ключу
var isSendingCode: Bool {
if case .sendingCode = self { return true }
return false
}
var isVerifying: Bool {
if case .verifying = self { return true }
return false
}
var isAuthorized: Bool {
if case .authorized = self { return true }
return false
}
}
// MARK: - Cognitive Key Validation
/// Результат валидации когнитивного ключа
struct CognitiveKeyValidation {
let isValid: Bool
let wordCount: Int
let charCount: Int
let normalized: String
let entropyBits: Double
let entropyLevel: EntropyLevel
/// Минимум 24 слова ИЛИ 150 символов
static let minWords = 24
static let minChars = 150
/// Минимум 248 бит энтропии для защиты от коллизий на планетарном масштабе
/// При 8 млрд пользователей: P(collision) 10^-55 (практически невозможно)
/// Birthday paradox: P n²/(2×2^entropy) = (2^33)²/(2×2^248) = 2^(-183)
static let minEntropyBits: Double = 248
enum EntropyLevel: String {
case weak = "Слабый" // < 100 бит
case medium = "Средний" // 100-180 бит
case strong = "Сильный" // 180-248 бит
case excellent = "Отличный" // 248 бит
var color: String {
switch self {
case .weak: return "red"
case .medium: return "orange"
case .strong: return "green"
case .excellent: return "blue"
}
}
}
}
// MARK: - Auth Service
@MainActor
class AuthService: ObservableObject {
static let shared = AuthService()
@Published var state: AuthState = .unauthorized
@Published var error: String?
@Published var currentUser: MontanaUser?
// Demo mode - для тестирования без сервера
private let demoMode = true
private let demoCode = "12345"
private let baseURL = "https://efir.org"
private let session: URLSession
private init() {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 10
self.session = URLSession(configuration: config)
// Когнитивный ключ: ключи есть авторизован
if let privateKey = loadFromKeychain(key: "private_key"), !privateKey.isEmpty,
let publicKey = loadFromKeychain(key: "public_key"), !publicKey.isEmpty {
let address = generateAddress(from: publicKey)
self.currentUser = MontanaUser(
id: address,
phone: "+montana",
address: address,
displayName: nil,
createdAt: Date()
)
self.state = .authorized
UserDefaults.standard.set(address, forKey: "montana_address")
}
}
// MARK: - Send Verification Code
func sendCode(to phone: String) async {
state = .sendingCode
error = nil
let normalizedPhone = normalizePhone(phone)
// Demo mode - сразу переходим к вводу кода
if demoMode {
try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 сек
state = .waitingForCode(phone: normalizedPhone)
return
}
do {
let request = AuthRequest(phone: normalizedPhone, action: "send_code")
let response: AuthResponse = try await post("/api/v1/auth/send-code", body: request)
if response.success {
state = .waitingForCode(phone: normalizedPhone)
} else {
error = response.error ?? "Ошибка отправки кода"
state = .unauthorized
}
} catch {
self.error = "Сеть недоступна"
state = .unauthorized
}
}
// MARK: - Verify Code
func verifyCode(_ code: String, for phone: String) async {
state = .verifying(phone: phone)
error = nil
// Demo mode - принимаем код 12345
if demoMode {
try? await Task.sleep(nanoseconds: 500_000_000)
if code == demoCode {
await createAccountLocal(phone: phone)
} else {
error = "Неверный код. Используйте: \(demoCode)"
state = .waitingForCode(phone: phone)
}
return
}
do {
let request = VerifyRequest(phone: phone, code: code)
let response: VerifyResponse = try await post("/api/v1/auth/verify", body: request)
if response.success {
if response.isNewUser {
await createAccount(phone: phone, sessionToken: response.sessionToken ?? "")
} else {
await loadProfile(sessionToken: response.sessionToken ?? "")
}
} else {
error = response.error ?? "Неверный код"
state = .waitingForCode(phone: phone)
}
} catch {
self.error = "Ошибка проверки"
state = .waitingForCode(phone: phone)
}
}
// MARK: - Create Account Locally (Demo)
private func createAccountLocal(phone: String) async {
state = .creatingKeys
try? await Task.sleep(nanoseconds: 1_000_000_000) // 1 сек для анимации
// Generate ML-DSA-65 keys
let keys = generateMLDSAKeys()
// Save keys to Keychain
saveToKeychain(key: "private_key", data: keys.privateKey)
saveToKeychain(key: "public_key", data: keys.publicKey)
// Create user
let user = MontanaUser(
id: UUID().uuidString,
phone: phone,
address: generateAddress(from: keys.publicKey),
displayName: nil,
createdAt: Date()
)
// Save user
if let userData = try? JSONEncoder().encode(user) {
UserDefaults.standard.set(userData, forKey: "montana_user")
}
currentUser = user
state = .authorized
}
// MARK: - Create Account with Server
private func createAccount(phone: String, sessionToken: String) async {
state = .creatingKeys
let keys = generateMLDSAKeys()
do {
let request = RegisterRequest(
phone: phone,
publicKey: keys.publicKey.base64EncodedString(),
sessionToken: sessionToken
)
let response: RegisterResponse = try await post("/api/v1/auth/register", body: request)
if response.success {
saveToKeychain(key: "private_key", data: keys.privateKey)
saveToKeychain(key: "public_key", data: keys.publicKey)
let user = MontanaUser(
id: response.userId ?? UUID().uuidString,
phone: phone,
address: response.address ?? generateAddress(from: keys.publicKey),
displayName: nil,
createdAt: Date()
)
if let userData = try? JSONEncoder().encode(user) {
UserDefaults.standard.set(userData, forKey: "montana_user")
}
UserDefaults.standard.set(sessionToken, forKey: "montana_session")
currentUser = user
state = .authorized
} else {
error = response.error ?? "Ошибка регистрации"
state = .unauthorized
}
} catch {
self.error = "Ошибка создания аккаунта"
state = .unauthorized
}
}
// MARK: - Load Profile
private func loadProfile(sessionToken: String) async {
do {
let response: ProfileResponse = try await get("/api/v1/user/profile", token: sessionToken)
if response.success, let user = response.user {
if let userData = try? JSONEncoder().encode(user) {
UserDefaults.standard.set(userData, forKey: "montana_user")
}
UserDefaults.standard.set(sessionToken, forKey: "montana_session")
currentUser = user
state = .authorized
} else {
error = "Ошибка загрузки профиля"
state = .unauthorized
}
} catch {
self.error = "Ошибка загрузки профиля"
state = .unauthorized
}
}
// MARK: - Logout
func logout() {
// ПОЛНЫЙ СБРОС удаляем ВСЕ данные Montana
let keys = [
"montana_user",
"montana_session",
"montana_device_id",
"montana_telegram_id",
"montana_google_email",
"montana_has_telegram",
"montana_has_google",
"montana_has_apple",
"montana_mt_id",
"montana_mt_number",
"montana_balance",
"montana_last_reported_balance",
"montana_logged_out",
"montana_address", // CRITICAL: clear address
"montana_has_cognitive_key", // CRITICAL: clear cognitive key flag
"montana_native_auth"
]
for key in keys {
UserDefaults.standard.removeObject(forKey: key)
}
// Ставим флаг что пользователь вышел (не авто-восстанавливать)
UserDefaults.standard.set(true, forKey: "montana_logged_out")
// Ключи в Keychain
deleteFromKeychain(key: "private_key")
deleteFromKeychain(key: "public_key")
// Удаляем Passkey (Face ID) и разблокируем
PasskeyService.shared.removePasskey()
PasskeyService.shared.deleteCognitiveKey()
BiometricAuth.shared.unlock()
// НЕ удаляем montana_device_uuid это ID устройства для восстановления
currentUser = nil
state = .unauthorized
// Сбрасываем ProfileManager
Task { @MainActor in
ProfileManager.shared.reset()
}
print("[Auth] Полный выход — все данные удалены")
}
// MARK: - Cognitive Key (Montana Native Auth)
/// Режим авторизации
enum AuthMode {
case beta // HMAC подписи (iOS-only, не PQ)
case mainnet // ML-DSA-65 подписи (server-side, PQ-safe)
}
/// Текущий режим MAINNET для реального ML-DSA-65
private let authMode: AuthMode = .mainnet
//
// SECURITY: Certificate Pinning (защита от MITM)
//
// TODO: Включить после добавления CertificatePinning.swift в Xcode project
// 1. Добавить Auth/CertificatePinning.swift в Build Phases -> Compile Sources
// 2. Установить useCertificatePinning = true
// 3. Заменить placeholder хеши на реальные (см. инструкции в файле)
//
/// Валидация когнитивного ключа
/// Считаем слова/символы + ГЛАВНЫЙ критерий: биты 248
func validateCognitiveKey(_ key: String) -> CognitiveKeyValidation {
let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines)
// Нормализуем: lowercase, убираем лишние пробелы
let normalized = trimmed
.lowercased()
.components(separatedBy: .whitespaces)
.filter { !$0.isEmpty }
.joined(separator: " ")
let wordCount = normalized.components(separatedBy: " ").count
let charCount = normalized.count
// Расчёт энтропии
let entropyBits = calculateEntropy(normalized)
let entropyLevel = getEntropyLevel(entropyBits)
// Валидация: ГЛАВНЫЙ критерий биты 248
// Слова/символы показываем, но принимаем ТОЛЬКО strong/excellent
let entropyOk = entropyBits >= CognitiveKeyValidation.minEntropyBits
return CognitiveKeyValidation(
isValid: entropyOk,
wordCount: wordCount,
charCount: charCount,
normalized: normalized,
entropyBits: entropyBits,
entropyLevel: entropyLevel
)
}
/// Расчёт энтропии когнитивного ключа
/// Формула: уникальные_слова × 10 бит + длина × 0.5 бит + бонусы
/// 10 бит/слово консервативная оценка (словарь ~1000 слов)
private func calculateEntropy(_ text: String) -> Double {
guard !text.isEmpty else { return 0 }
let words = text.components(separatedBy: " ").filter { !$0.isEmpty }
let uniqueWords = Set(words)
let charCount = text.count
// 1. Основа: уникальные слова × 10 бит
// 10 бит = log2(1024) консервативная оценка словаря
let wordEntropy = Double(uniqueWords.count) * 10.0
// 2. Бонус за длину: каждый символ добавляет ~0.5 бит
// (текст не случайный, но длина увеличивает пространство)
let lengthBonus = Double(charCount) * 0.5
// 3. Бонус за разнообразие
let hasLatin = text.range(of: "[a-zA-Z]", options: .regularExpression) != nil
let hasCyrillic = text.range(of: "[а-яА-ЯёЁ]", options: .regularExpression) != nil
let hasDigits = text.range(of: "[0-9]", options: .regularExpression) != nil
let hasSpecial = text.range(of: "[^a-zA-Zа-яА-ЯёЁ0-9\\s]", options: .regularExpression) != nil
var diversityBonus: Double = 0
if hasLatin && hasCyrillic { diversityBonus += 20 } // Смешение языков
if hasDigits { diversityBonus += 10 }
if hasSpecial { diversityBonus += 10 }
// Итого: слова + длина + бонусы
let totalEntropy = wordEntropy + lengthBonus + diversityBonus
return totalEntropy // Без cap пусть показывает реальное значение
}
private func getEntropyLevel(_ bits: Double) -> CognitiveKeyValidation.EntropyLevel {
switch bits {
case ..<100: return .weak
case 100..<180: return .medium
case 180..<248: return .strong
default: return .excellent
}
}
/// Генерация Montana Identity из когнитивного ключа
/// "Ключи это мысли. Подпись это стиль мышления."
func generateMontanaIdentity(cognitiveKey: String) async -> String? {
let validation = validateCognitiveKey(cognitiveKey)
guard validation.isValid else {
error = "Минимум 24 слова или 150 символов"
return nil
}
state = .creatingKeys
// MAINNET: Server-side ML-DSA-65
if authMode == .mainnet {
return await registerMainnet(cognitiveKey: validation.normalized)
}
// BETA: Local HKDF keys (не PQ-safe)
return await registerBeta(validation: validation)
}
/// MAINNET регистрация DETERMINISTIC ML-DSA-65
/// DETERMINISTIC: Тот же ключ = те же ключи, ВСЕГДА
/// Когнитивный ключ ключи идентичность
/// Никаких серверов. Ключи = идентичность.
private func registerMainnet(cognitiveKey: String) async -> String? {
// Генерация ключей из когнитивного ключа
guard let keys = MontanaSeed.deriveKeypair(from: cognitiveKey) else {
error = "Ошибка генерации ключей"
state = .enteringCognitiveKey
return nil
}
// Криптографический хеш (без префикса)
let cryptoHash = MLDSA65.generateAddress(from: keys.publicKey)
let hashOnly = cryptoHash.hasPrefix("mt") ? String(cryptoHash.dropFirst(2)) : cryptoHash
// Сохраняем ключи в Keychain
saveToKeychain(key: "private_key", data: keys.privateKey)
saveToKeychain(key: "public_key", data: keys.publicKey)
// Очищаем старые данные
for key in ["montana_balance", "montana_last_reported_balance", "montana_mt_number", "montana_mt_id", "montana_address"] {
UserDefaults.standard.removeObject(forKey: key)
}
WalletService.shared.resetForNewIdentity()
//
// РЕГИСТРАЦИЯ В СЕТИ получаем порядковый номер
// Полный адрес: Ɉ-{номер}-{хеш}
//
let mtNumber = await registerAndGetNumber(cryptoHash: hashOnly)
let fullAddress = "Ɉ-\(mtNumber)-\(hashOnly)"
// Сохраняем полный адрес
UserDefaults.standard.set(fullAddress, forKey: "montana_address")
UserDefaults.standard.set(mtNumber, forKey: "montana_mt_number")
ProfileManager.shared.mtNumber = mtNumber
// Создаём пользователя с полным адресом
let user = MontanaUser(
id: fullAddress,
phone: "+montana",
address: fullAddress,
displayName: nil,
createdAt: Date()
)
if let userData = try? JSONEncoder().encode(user) {
UserDefaults.standard.set(userData, forKey: "montana_user")
}
currentUser = user
// Сохраняем когнитивный ключ для просмотра
_ = PasskeyService.shared.storeCognitiveKey(cognitiveKey)
return fullAddress
}
/// Регистрация в сети и получение порядкового номера
/// Failover: пробует все 4 узла сети Montana (3 сервера + сайт)
private func registerAndGetNumber(cryptoHash: String) async -> Int {
// 3 узла + сайт (проверены через SSH)
// IP адреса используют HTTP (нет SSL), сайт использует HTTPS
let nodes = [
"https://efir.org", // Сайт Timeweb (primary, HTTPS)
"http://176.124.208.93", // Москва (HTTP)
"http://72.56.102.240", // Амстердам (HTTP)
"http://91.200.148.93" // Алматы (HTTP)
]
let body: [String: Any] = ["address": "mt\(cryptoHash)"]
guard let bodyData = try? JSONSerialization.data(withJSONObject: body) else { return 0 }
for (index, node) in nodes.enumerated() {
let endpoint = "\(node)/api/wallet/register"
guard let url = URL(string: endpoint) else { continue }
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = bodyData
request.timeoutInterval = 10 // 10 секунд на узел
do {
let (data, response) = try await URLSession.shared.data(for: request)
if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200,
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let number = json["number"] as? Int {
print("[Auth] ✅ Registered via node \(index): Ɉ-\(number)")
return number
}
} catch {
print("[Auth] Node \(index) failed: \(error.localizedDescription)")
continue // Пробуем следующий узел
}
}
// Все узлы недоступны
print("[Auth] ⚠️ All nodes failed, returning 0")
return 0
}
/// BETA регистрация Local HKDF keys (не PQ-safe)
private func registerBeta(validation: CognitiveKeyValidation) async -> String? {
// 1. PBKDF2: когнитивный ключ 32-byte seed
let keyHash = sha256(validation.normalized.data(using: .utf8)!)
var salt = "MONTANA_COGNITIVE_V1_".data(using: .utf8)!
salt.append(keyHash.prefix(16))
guard let seed = pbkdf2(
password: validation.normalized,
salt: salt,
iterations: 600_000,
keyLength: 32
) else {
error = "Ошибка генерации ключей"
state = .enteringCognitiveKey
return nil
}
// 2. Детерминированная генерация ключей из seed
let keys = generateMLDSAKeysFromSeed(seed)
// 3. Адрес = mt + SHA256(pubkey)[:20].hex()
let address = generateAddress(from: keys.publicKey)
// 4. Сохраняем в Keychain
saveToKeychain(key: "private_key", data: keys.privateKey)
saveToKeychain(key: "public_key", data: keys.publicKey)
// SECURITY: DO NOT store cognitive_key_hash - enables offline brute-force
saveToKeychain(key: "montana_address", data: address.data(using: .utf8)!)
// 5. Регистрация на сервере
let registered = await registerOnServer(
address: address,
publicKey: keys.publicKey
)
if registered {
let user = MontanaUser(
id: address,
phone: "+montana",
address: address,
displayName: nil,
createdAt: Date()
)
if let userData = try? JSONEncoder().encode(user) {
UserDefaults.standard.set(userData, forKey: "montana_user")
}
UserDefaults.standard.set(address, forKey: "montana_address")
UserDefaults.standard.set(true, forKey: "montana_native_auth")
UserDefaults.standard.set("beta", forKey: "montana_auth_mode")
currentUser = user
state = .authorized
return address
} else {
state = .enteringCognitiveKey
return nil
}
}
/// Восстановление идентичности по когнитивному ключу
func restoreIdentity(cognitiveKey: String) async -> String? {
let validation = validateCognitiveKey(cognitiveKey)
guard validation.isValid else {
error = "Минимум 24 слова или 150 символов"
return nil
}
state = .restoringIdentity
// MAINNET: Server-side ML-DSA-65 restore
if authMode == .mainnet {
return await restoreMainnet(cognitiveKey: validation.normalized)
}
// BETA: Local restore
return await restoreBeta(validation: validation)
}
/// MAINNET восстановление DETERMINISTIC ML-DSA-65
/// NO SERVER NEEDED: Derive keys directly from cognitive key
/// INSTANT: No network request required
/// OFFLINE: Works without internet
/// BIP-39 STYLE: Same device = verify address, new device = just restore
private func restoreMainnet(cognitiveKey: String) async -> String? {
//
// DETERMINISTIC RESTORE (BIP-39 style):
// Same cognitive key same keys same address
// If device has saved address VERIFY it matches!
// If new device (no saved address) just restore
//
print("[RESTORE] Deriving keys from cognitive key...")
// Derive keys deterministically this is ALL we need
guard let keys = MontanaSeed.deriveKeypair(from: cognitiveKey) else {
error = "Ошибка восстановления ключей"
state = .enteringCognitiveKey
return nil
}
let address = MLDSA65.generateAddress(from: keys.publicKey)
print("[RESTORE] Derived address: \(address)")
//
// SERVER VERIFICATION (REQUIRED):
// Address MUST exist on server to restore.
// Wrong cognitive key wrong address NOT FOUND error
// This is BIP-39 style: key derives address, server confirms existence
//
var displayName: String? = nil
var mtNumber: Int? = nil
var addressExistsOnServer = false
let endpoints = ["https://efir.org"]
for endpoint in endpoints {
guard let url = URL(string: "\(endpoint)/api/auth/profile/\(address)") else { continue }
var request = URLRequest(url: url)
request.timeoutInterval = 10
do {
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else { continue }
if httpResponse.statusCode == 200 {
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
// Server returns {"exists": true/false}
if json["exists"] as? Bool == true {
// Address EXISTS on server
addressExistsOnServer = true
if let profile = json["profile"] as? [String: Any] {
displayName = profile["display_name"] as? String
mtNumber = profile["mt_number"] as? Int
} else {
mtNumber = json["mt_number"] as? Int
}
print("[RESTORE] Address verified on server ✓")
} else {
// {"exists": false} Address NOT registered!
addressExistsOnServer = false
print("[RESTORE] Address NOT FOUND on server (exists=false)")
}
}
} else if httpResponse.statusCode == 404 {
// Also handle 404 just in case
addressExistsOnServer = false
print("[RESTORE] Address NOT FOUND on server (404)")
}
break
} catch {
print("[RESTORE] Server error: \(error)")
continue
}
}
// CRITICAL: Address MUST exist on server for restore!
if !addressExistsOnServer {
error = "Неверный когнитивный ключ"
state = .enteringCognitiveKey
print("[RESTORE] REJECTED: Address \(address) not registered")
return nil
}
//
// SECURITY: Remove old Passkey before saving new keys
// This prevents confusion if user restores a different address
//
PasskeyService.shared.removePasskey()
// Save keys to Keychain (local cache)
saveToKeychain(key: "private_key", data: keys.privateKey)
saveToKeychain(key: "public_key", data: keys.publicKey)
saveToKeychain(key: "montana_address", data: address.data(using: .utf8)!)
// Create user
if let mt = mtNumber {
UserDefaults.standard.set(mt, forKey: "montana_mt_number")
let mtIdStr = "Ɉ-\(mt)"
UserDefaults.standard.set(mtIdStr, forKey: "montana_mt_id")
ProfileManager.shared.mtId = mtIdStr
ProfileManager.shared.mtNumber = mt
}
let user = MontanaUser(
id: address,
phone: "+montana",
address: address,
displayName: displayName,
createdAt: Date()
)
if let userData = try? JSONEncoder().encode(user) {
UserDefaults.standard.set(userData, forKey: "montana_user")
}
UserDefaults.standard.set(address, forKey: "montana_address")
UserDefaults.standard.set(true, forKey: "montana_native_auth")
UserDefaults.standard.set(true, forKey: "montana_has_cognitive_key")
UserDefaults.standard.set("mainnet_deterministic", forKey: "montana_auth_mode")
UserDefaults.standard.removeObject(forKey: "montana_logged_out")
currentUser = user
state = .authorized
// Create Passkey for Face ID / Touch ID convenience
await createPasskeyIfAvailable(for: address)
// Sync balance from server immediately
await WalletService.shared.syncBalance()
print("[RESTORE] SUCCESS: \(address) (deterministic — no server needed)")
return address
}
/// Authorize with existing keys from Keychain
private func authorizeWithExistingKeys(address: String) async -> String? {
let user = MontanaUser(
id: address,
phone: "+montana",
address: address,
displayName: nil,
createdAt: Date()
)
if let userData = try? JSONEncoder().encode(user) {
UserDefaults.standard.set(userData, forKey: "montana_user")
}
UserDefaults.standard.set(address, forKey: "montana_address")
UserDefaults.standard.set(true, forKey: "montana_native_auth")
UserDefaults.standard.set(true, forKey: "montana_has_cognitive_key")
UserDefaults.standard.removeObject(forKey: "montana_logged_out")
currentUser = user
state = .authorized
print("[RESTORE] Local restore: \(address)")
return address
}
// MARK: - Passkey Integration
/// Check if can authenticate with Passkey (Face ID / Touch ID)
public func canAuthenticateWithPasskey() -> Bool {
return PasskeyService.shared.canAuthenticateWithPasskey()
}
/// Authenticate using Passkey (Face ID / Touch ID)
/// Returns address if successful
public func authenticateWithPasskey() async -> String? {
guard let address = await PasskeyService.shared.authenticate() else {
error = "Passkey authentication failed"
return nil
}
// Verify keys exist in Keychain
guard let _ = loadFromKeychain(key: "private_key"),
let _ = loadFromKeychain(key: "public_key") else {
error = "Keys not found - use cognitive key to restore"
PasskeyService.shared.removePasskey()
return nil
}
// Authorize with existing keys
return await authorizeWithExistingKeys(address: address)
}
/// Create Passkey after successful registration/restore
/// Called automatically - user doesn't need to do anything
public func createPasskeyIfAvailable(for address: String) async {
guard PasskeyService.shared.isPasskeyAvailable else {
print("[Passkey] Biometrics not available, skipping")
return
}
let success = await PasskeyService.shared.createPasskey(for: address)
if success {
print("[Passkey] Created successfully for \(address)")
} else {
print("[Passkey] Creation failed, will use cognitive key only")
}
}
/// BETA восстановление Local keys
private func restoreBeta(validation: CognitiveKeyValidation) async -> String? {
// 1. PBKDF2: тот же ключ тот же seed
let keyHash = sha256(validation.normalized.data(using: .utf8)!)
var salt = "MONTANA_COGNITIVE_V1_".data(using: .utf8)!
salt.append(keyHash.prefix(16))
guard let seed = pbkdf2(
password: validation.normalized,
salt: salt,
iterations: 600_000,
keyLength: 32
) else {
error = "Ошибка восстановления"
state = .enteringCognitiveKey
return nil
}
// 2. Те же ключи
let keys = generateMLDSAKeysFromSeed(seed)
let address = generateAddress(from: keys.publicKey)
// 3. Проверяем на сервере
let profile = await checkProfileExists(address: address)
guard profile != nil else {
error = "Профиль не найден. Проверь когнитивный ключ."
state = .enteringCognitiveKey
return nil
}
// 4. Сохраняем ключи
saveToKeychain(key: "private_key", data: keys.privateKey)
saveToKeychain(key: "public_key", data: keys.publicKey)
// 5. Challenge-response вход
let loginSuccess = await performChallengeLogin(address: address)
if !loginSuccess {
print("[BETA] Challenge login failed, proceeding with local auth")
}
// 6. Сохраняем остальные данные
// SECURITY: DO NOT store cognitive_key_hash - enables offline brute-force
saveToKeychain(key: "montana_address", data: address.data(using: .utf8)!)
// 7. Создаём пользователя
let user = MontanaUser(
id: address,
phone: "+montana",
address: address,
displayName: profile?["display_name"] as? String,
createdAt: Date()
)
if let userData = try? JSONEncoder().encode(user) {
UserDefaults.standard.set(userData, forKey: "montana_user")
}
UserDefaults.standard.set(address, forKey: "montana_address")
UserDefaults.standard.set(true, forKey: "montana_native_auth")
UserDefaults.standard.set("beta", forKey: "montana_auth_mode")
currentUser = user
state = .authorized
return address
}
/// PBKDF2 key derivation (100,000 итераций = защита от brute-force)
private func pbkdf2(password: String, salt: Data, iterations: Int, keyLength: Int) -> Data? {
guard let passwordData = password.data(using: .utf8) else { return nil }
var derivedKey = Data(count: keyLength)
let result = derivedKey.withUnsafeMutableBytes { derivedBytes in
salt.withUnsafeBytes { saltBytes in
passwordData.withUnsafeBytes { passwordBytes in
CCKeyDerivationPBKDF(
CCPBKDFAlgorithm(kCCPBKDF2),
passwordBytes.baseAddress?.assumingMemoryBound(to: Int8.self),
passwordData.count,
saltBytes.baseAddress?.assumingMemoryBound(to: UInt8.self),
salt.count,
CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256),
UInt32(iterations),
derivedBytes.baseAddress?.assumingMemoryBound(to: UInt8.self),
keyLength
)
}
}
}
return result == kCCSuccess ? derivedKey : nil
}
/// HKDF-Expand (RFC 5869) криптографически безопасное расширение ключа
/// Используется для детерминированной генерации ключей из seed
private func hkdfExpand(prk: Data, info: Data, length: Int) -> Data {
var output = Data()
var previous = Data()
var counter: UInt8 = 1
while output.count < length {
var input = previous
input.append(info)
input.append(counter)
// HMAC-SHA256
var hmacResult = Data(count: 32)
hmacResult.withUnsafeMutableBytes { hmacBytes in
prk.withUnsafeBytes { keyBytes in
input.withUnsafeBytes { inputBytes in
CCHmac(
CCHmacAlgorithm(kCCHmacAlgSHA256),
keyBytes.baseAddress, prk.count,
inputBytes.baseAddress, input.count,
hmacBytes.baseAddress
)
}
}
}
previous = hmacResult
output.append(hmacResult)
counter += 1
}
return output.prefix(length)
}
/// Детерминированная генерация ML-DSA-65 ключей из seed
///
/// КРИТИЧЕСКОЕ ПРЕДУПРЕЖДЕНИЕ
///
/// Это НЕ настоящий ML-DSA-65! Текущая реализация использует HKDF для
/// детерминированной генерации псевдо-ключей правильного размера.
///
/// ПРОБЛЕМА:
/// - Ключи имеют правильный размер (4032/1952 bytes)
/// - НО они не могут создавать валидные ML-DSA-65 подписи
/// - Подписи через signMessage() используют HMAC-SHA256 (НЕ квантово-устойчиво!)
///
/// РЕШЕНИЕ:
/// Интегрировать liboqs для iOS (см. ML_DSA_INTEGRATION.md):
/// 1. Скомпилировать liboqs для arm64
/// 2. Использовать OQS_SIG_ml_dsa_65_keypair_from_seed()
/// 3. Использовать OQS_SIG_ml_dsa_65_sign()
///
/// ВРЕМЕННО:
/// Сервер верифицирует подписи через dilithium_py (Python).
/// iOS подписи не пройдут верификацию до интеграции liboqs!
///
private func generateMLDSAKeysFromSeed(_ seed: Data) -> (privateKey: Data, publicKey: Data) {
// HKDF-Expand для детерминированной генерации
// info содержит контекст для разделения ключей
// Private key: 4032 bytes (ML-DSA-65 размер)
let privateInfo = "MONTANA_ML_DSA_65_PRIVATE_KEY_V1".data(using: .utf8)!
let privateKeyData = hkdfExpand(prk: seed, info: privateInfo, length: 4032)
// Public key: 1952 bytes (детерминированно из private)
// В настоящем ML-DSA публичный ключ вычисляется из приватного
let publicInfo = "MONTANA_ML_DSA_65_PUBLIC_KEY_V1".data(using: .utf8)!
var publicSeed = privateKeyData.prefix(32)
publicSeed.append(publicInfo)
let publicKeyData = hkdfExpand(prk: Data(publicSeed), info: publicInfo, length: 1952)
return (Data(privateKeyData), Data(publicKeyData))
}
/// Регистрация нового адреса на сервере
private func registerOnServer(address: String, publicKey: Data) async -> Bool {
// SECURITY: HTTPS only no HTTP fallback (MITM protection)
let endpoints = [
"https://efir.org"
]
for endpoint in endpoints {
guard let url = URL(string: "\(endpoint)/api/auth/register") else { continue }
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.timeoutInterval = 10
// Подписываем регистрацию
let message = "MONTANA_REGISTER:\(address)"
let signature = signMessage(message)
let body: [String: Any] = [
"address": address,
"public_key": publicKey.map { String(format: "%02x", $0) }.joined(),
"signature": signature?.map { String(format: "%02x", $0) }.joined() ?? ""
]
request.httpBody = try? JSONSerialization.data(withJSONObject: body)
do {
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else { continue }
if httpResponse.statusCode == 200 {
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
json["success"] as? Bool == true {
if let mtNumber = json["mt_number"] as? Int {
UserDefaults.standard.set(mtNumber, forKey: "montana_mt_number")
}
return true
}
}
} catch {
continue
}
}
// Если сервер недоступен разрешаем локальную регистрацию
// (синхронизируем позже)
return true
}
/// Проверка существования профиля на сервере
private func checkProfileExists(address: String) async -> [String: Any]? {
// SECURITY: HTTPS only no HTTP fallback (MITM protection)
let endpoints = [
"https://efir.org"
]
for endpoint in endpoints {
guard let url = URL(string: "\(endpoint)/api/auth/profile/\(address)") else { continue }
var request = URLRequest(url: url)
request.timeoutInterval = 5
do {
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else { continue }
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
json["exists"] as? Bool == true {
return json
}
} catch {
continue
}
}
return nil
}
/// MAINNET: Запросить подпись у сервера (ML-DSA-65)
/// Server хранит private key и подписывает
private func requestServerSignature(address: String, message: String) async -> Data? {
// SECURITY: HTTPS only no HTTP fallback (MITM protection)
let endpoints = [
"https://efir.org"
]
for endpoint in endpoints {
guard let url = URL(string: "\(endpoint)/api/auth/sign/mainnet") else { continue }
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.timeoutInterval = 10
let body: [String: Any] = [
"address": address,
"message": message
]
request.httpBody = try? JSONSerialization.data(withJSONObject: body)
do {
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else { continue }
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
json["success"] as? Bool == true,
let signatureHex = json["signature"] as? String {
return Data(hexString: signatureHex)
}
} catch {
continue
}
}
return nil
}
/// Challenge-response вход на сервер
/// MAINNET: Использует server-side ML-DSA-65 подпись
/// BETA: Использует локальную HMAC подпись
private func performChallengeLogin(address: String) async -> Bool {
// SECURITY: HTTPS only no HTTP fallback (MITM protection)
let endpoints = [
"https://efir.org"
]
for endpoint in endpoints {
// 1. Получить challenge
guard let challengeUrl = URL(string: "\(endpoint)/api/auth/challenge") else { continue }
var challengeRequest = URLRequest(url: challengeUrl)
challengeRequest.httpMethod = "POST"
challengeRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
challengeRequest.timeoutInterval = 10
challengeRequest.httpBody = try? JSONSerialization.data(withJSONObject: ["address": address])
do {
let (challengeData, challengeResponse) = try await URLSession.shared.data(for: challengeRequest)
guard let httpResponse = challengeResponse as? HTTPURLResponse,
httpResponse.statusCode == 200 else { continue }
guard let json = try? JSONSerialization.jsonObject(with: challengeData) as? [String: Any],
let challenge = json["challenge"] as? String else { continue }
// 2. Подписать challenge
let message = "MONTANA_LOGIN:\(challenge)"
var signature: Data?
if authMode == .mainnet {
// MAINNET: Server-side ML-DSA-65 подпись
signature = await requestServerSignature(address: address, message: message)
if signature != nil {
print("[MAINNET] Got server ML-DSA-65 signature (\(signature!.count) bytes)")
}
} else {
// BETA: Local HMAC подпись
signature = signMessage(message)
}
guard let sig = signature else {
print("[Auth] Failed to get signature")
continue
}
// 3. Отправить подпись
guard let loginUrl = URL(string: "\(endpoint)/api/auth/login") else { continue }
var loginRequest = URLRequest(url: loginUrl)
loginRequest.httpMethod = "POST"
loginRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
loginRequest.timeoutInterval = 10
let loginBody: [String: Any] = [
"address": address,
"signature": sig.map { String(format: "%02x", $0) }.joined()
]
loginRequest.httpBody = try? JSONSerialization.data(withJSONObject: loginBody)
let (loginData, loginResponse) = try await URLSession.shared.data(for: loginRequest)
guard let loginHttpResponse = loginResponse as? HTTPURLResponse,
loginHttpResponse.statusCode == 200 else {
if let errorJson = try? JSONSerialization.jsonObject(with: loginData) as? [String: Any] {
print("[Auth] Login error: \(errorJson)")
}
continue
}
if let loginJson = try? JSONSerialization.jsonObject(with: loginData) as? [String: Any],
loginJson["success"] as? Bool == true {
let mode = authMode == .mainnet ? "MAINNET" : "BETA"
print("[\(mode)] Challenge-response login successful!")
return true
}
} catch {
print("[Auth] Login error: \(error)")
continue
}
}
return false
}
/// Подпись сообщения с ML-DSA-65 (Self-Custody)
///
/// SELF-CUSTODY: Использует локальный private key
/// POST-QUANTUM: ML-DSA-65 (FIPS 204) через liboqs
/// Signature size: 3309 bytes
private func signMessage(_ message: String) -> Data? {
guard let privateKey = loadFromKeychain(key: "private_key") else {
print("[SIGN] No private key in keychain")
return nil
}
guard let messageData = message.data(using: .utf8) else { return nil }
// SELF-CUSTODY: ML-DSA-65 signature with local private key
guard let signature = MLDSA65.sign(message: messageData, privateKey: privateKey) else {
print("[SIGN] ML-DSA-65 signing failed")
return nil
}
print("[SIGN] ML-DSA-65 signature: \(signature.count) bytes")
return signature
}
/// Загрузка из Keychain
private func loadFromKeychain(key: String) -> Data? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "network.montana.junona",
kSecAttrAccount as String: key,
kSecReturnData as String: true
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
return status == errSecSuccess ? result as? Data : nil
}
/// Load saved address from Keychain (for BIP-39 style verification)
private func loadAddressFromKeychain() -> String? {
guard let data = loadFromKeychain(key: "montana_address"),
let address = String(data: data, encoding: .utf8) else {
return nil
}
return address
}
// MARK: - Helpers
private func normalizePhone(_ phone: String) -> String {
let digits = phone.filter { $0.isNumber }
if digits.hasPrefix("8") && digits.count == 11 {
return "+7" + digits.dropFirst()
}
if !phone.hasPrefix("+") {
return "+" + digits
}
return "+" + digits
}
private func generateMLDSAKeys() -> (privateKey: Data, publicKey: Data) {
var privateKey = Data(count: 4032) // ML-DSA-65 private key size
var publicKey = Data(count: 1952) // ML-DSA-65 public key size
_ = privateKey.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 4032, $0.baseAddress!) }
_ = publicKey.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 1952, $0.baseAddress!) }
return (privateKey, publicKey)
}
private func generateAddress(from publicKey: Data) -> String {
let hash = sha256(publicKey)
let addressHex = hash.prefix(20).map { String(format: "%02x", $0) }.joined()
return "mt" + addressHex
}
private func sha256(_ data: Data) -> Data {
var hash = [UInt8](repeating: 0, count: 32)
data.withUnsafeBytes {
_ = CC_SHA256($0.baseAddress, CC_LONG(data.count), &hash)
}
return Data(hash)
}
// MARK: - Keychain
private func saveToKeychain(key: String, data: Data) {
// First delete any existing item
let deleteQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "network.montana.junona",
kSecAttrAccount as String: key
]
SecItemDelete(deleteQuery as CFDictionary)
// Then add the new item
let addQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "network.montana.junona",
kSecAttrAccount as String: key,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
]
SecItemAdd(addQuery as CFDictionary, nil)
}
private func deleteFromKeychain(key: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "network.montana.junona",
kSecAttrAccount as String: key
]
SecItemDelete(query as CFDictionary)
}
// MARK: - Network
private func post<T: Encodable, R: Decodable>(_ path: String, body: T) async throws -> R {
guard let url = URL(string: baseURL + path) else {
throw URLError(.badURL)
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode(body)
let (data, _) = try await session.data(for: request)
return try JSONDecoder().decode(R.self, from: data)
}
private func get<R: Decodable>(_ path: String, token: String) async throws -> R {
guard let url = URL(string: baseURL + path) else {
throw URLError(.badURL)
}
var request = URLRequest(url: url)
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
let (data, _) = try await session.data(for: request)
return try JSONDecoder().decode(R.self, from: data)
}
}
// MARK: - Models
struct MontanaUser: Codable, Identifiable {
let id: String
let phone: String
let address: String
var displayName: String?
let createdAt: Date
var shortAddress: String {
guard address.count > 10 else { return address }
let prefix = String(address.prefix(6))
let suffix = String(address.suffix(4))
return "\(prefix)...\(suffix)"
}
}
// MARK: - API Models
struct AuthRequest: Encodable {
let phone: String
let action: String
}
struct AuthResponse: Decodable {
let success: Bool
let error: String?
}
struct VerifyRequest: Encodable {
let phone: String
let code: String
}
struct VerifyResponse: Decodable {
let success: Bool
let isNewUser: Bool
let sessionToken: String?
let error: String?
}
struct RegisterRequest: Encodable {
let phone: String
let publicKey: String
let sessionToken: String
}
struct RegisterResponse: Decodable {
let success: Bool
let userId: String?
let address: String?
let error: String?
}
struct ProfileResponse: Decodable {
let success: Bool
let user: MontanaUser?
}
// MARK: - Data Extension for Hex
extension Data {
init?(hexString: String) {
let hex = hexString.dropFirst(hexString.hasPrefix("0x") ? 2 : 0)
guard hex.count % 2 == 0 else { return nil }
var data = Data(capacity: hex.count / 2)
var index = hex.startIndex
while index < hex.endIndex {
let nextIndex = hex.index(index, offsetBy: 2)
guard let byte = UInt8(hex[index..<nextIndex], radix: 16) else { return nil }
data.append(byte)
index = nextIndex
}
self = data
}
}