636 lines
23 KiB
Swift
636 lines
23 KiB
Swift
import Foundation
|
||
import Network
|
||
import AppKit
|
||
import Security
|
||
import CryptoKit
|
||
import CommonCrypto
|
||
|
||
@MainActor
|
||
class VPNManager: ObservableObject {
|
||
static let shared = VPNManager()
|
||
|
||
// Public API (same as before — PresenceEngine IMMUTABLE BLOCK compatible)
|
||
@Published var isConnected = false
|
||
@Published var isConnecting = false
|
||
@Published var isConfigured = false
|
||
@Published var vpnIP: String = ""
|
||
@Published var pingMs: Int = 0
|
||
@Published var serverLocation: String = "Amsterdam"
|
||
|
||
// WireGuard specific
|
||
@Published var connectionError: String? = nil
|
||
@Published var bytesIn: Int64 = 0
|
||
@Published var bytesOut: Int64 = 0
|
||
@Published var sessionStart: Date? = nil
|
||
@Published var publicKey: String = ""
|
||
@Published var needsSudo: Bool = false
|
||
|
||
var sessionDuration: Int {
|
||
guard let start = sessionStart else { return 0 }
|
||
return max(Int(Date().timeIntervalSince(start)), 0)
|
||
}
|
||
|
||
// WireGuard config
|
||
private let serverAddress = "72.56.102.240"
|
||
private let serverPort = 51820
|
||
private let serverPublicKey = "/9zhnW4O4uOstQpR5mgGmCLiy+B+LL4uQmNzgupNzwc="
|
||
private let subnet = "10.66.66"
|
||
private let interfaceName = "utun9"
|
||
|
||
// Monitoring
|
||
private var pathMonitor: NWPathMonitor?
|
||
private var checkTimer: Timer?
|
||
private var sessionTimer: Timer?
|
||
private var wgProcess: Process?
|
||
|
||
// Paths
|
||
private var configDir: URL {
|
||
guard let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else {
|
||
fatalError("Application Support directory not available")
|
||
}
|
||
return appSupport.appendingPathComponent("Montana")
|
||
}
|
||
|
||
nonisolated private static var homebrewPrefix: String {
|
||
#if arch(arm64)
|
||
return "/opt/homebrew"
|
||
#else
|
||
return "/usr/local"
|
||
#endif
|
||
}
|
||
private var configPath: String {
|
||
configDir.appendingPathComponent("wg0.conf").path
|
||
}
|
||
private var privateKeyPath: String {
|
||
configDir.appendingPathComponent("private.key.enc").path
|
||
}
|
||
|
||
// Montana Protocol keypair for encryption (post-quantum protection)
|
||
private var montanaPrivateKey: Data? {
|
||
// Load Montana Protocol private key from PresenceEngine
|
||
// This is ML-DSA-65 (Dilithium) keypair - post-quantum secure
|
||
guard var keyData = UserDefaults.standard.data(forKey: "montana_private_key") else { return nil }
|
||
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
// SECURITY: Secure Memory Wiping
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
// Zero out sensitive key material after use to prevent memory dumps
|
||
defer {
|
||
keyData.withUnsafeMutableBytes { ptr in
|
||
guard let baseAddress = ptr.baseAddress else { return }
|
||
memset_s(baseAddress, ptr.count, 0, ptr.count)
|
||
}
|
||
}
|
||
|
||
return keyData
|
||
}
|
||
|
||
private init() {
|
||
setupConfigDirectory()
|
||
loadOrGenerateKeys()
|
||
startPathMonitor()
|
||
startStatusPolling()
|
||
}
|
||
|
||
deinit {
|
||
pathMonitor?.cancel()
|
||
checkTimer?.invalidate()
|
||
sessionTimer?.invalidate()
|
||
wgProcess?.terminate()
|
||
}
|
||
|
||
// MARK: - Setup
|
||
|
||
private func setupConfigDirectory() {
|
||
try? FileManager.default.createDirectory(at: configDir, withIntermediateDirectories: true)
|
||
}
|
||
|
||
private func loadOrGenerateKeys() {
|
||
// Check if encrypted private key exists on disk
|
||
if FileManager.default.fileExists(atPath: privateKeyPath),
|
||
let privateKey = loadEncryptedPrivateKey(), !privateKey.isEmpty {
|
||
// Derive public key from private key
|
||
if let pubKey = derivePublicKey(from: privateKey) {
|
||
self.publicKey = pubKey
|
||
self.isConfigured = true
|
||
return
|
||
}
|
||
}
|
||
|
||
// Generate new keypair
|
||
generateNewKeypair()
|
||
}
|
||
|
||
private func generateNewKeypair() {
|
||
let privateKey = Self.runProcess("\(Self.homebrewPrefix)/bin/wg", args: ["genkey"]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !privateKey.isEmpty else {
|
||
connectionError = "Montana VPN: WireGuard не установлен. Запустите: brew install wireguard-tools"
|
||
return
|
||
}
|
||
|
||
// Save private key encrypted with post-quantum protection
|
||
guard saveEncryptedPrivateKey(privateKey) else {
|
||
connectionError = "Ошибка сохранения зашифрованного ключа"
|
||
return
|
||
}
|
||
|
||
// Derive public key
|
||
if let pubKey = derivePublicKey(from: privateKey) {
|
||
self.publicKey = pubKey
|
||
self.isConfigured = true
|
||
}
|
||
}
|
||
|
||
private func derivePublicKey(from privateKey: String) -> String? {
|
||
let task = Process()
|
||
task.executableURL = URL(fileURLWithPath: "\(Self.homebrewPrefix)/bin/wg")
|
||
task.arguments = ["pubkey"]
|
||
|
||
let inputPipe = Pipe()
|
||
let outputPipe = Pipe()
|
||
task.standardInput = inputPipe
|
||
task.standardOutput = outputPipe
|
||
task.standardError = Pipe()
|
||
|
||
do {
|
||
try task.run()
|
||
inputPipe.fileHandleForWriting.write(privateKey.data(using: .utf8)!)
|
||
try inputPipe.fileHandleForWriting.close()
|
||
task.waitUntilExit()
|
||
let data = outputPipe.fileHandleForReading.readDataToEndOfFile()
|
||
return String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
} catch {
|
||
return nil
|
||
}
|
||
}
|
||
|
||
// MARK: - Post-Quantum Encryption (via Montana Protocol keypair)
|
||
|
||
/// Derive AES-256 encryption key from Montana Protocol private key
|
||
private func deriveEncryptionKey() -> SymmetricKey? {
|
||
guard let montanaKey = montanaPrivateKey else {
|
||
// Fallback: use device-specific key if Montana key not available yet
|
||
let fallbackSeed = "Montana-VPN-\(ProcessInfo.processInfo.hostName)".data(using: .utf8)!
|
||
return SymmetricKey(data: SHA256.hash(data: fallbackSeed))
|
||
}
|
||
|
||
// KDF from Montana Protocol private key (ML-DSA-65)
|
||
// This provides post-quantum security for VPN key encryption
|
||
let salt = "Montana-VPN-v1".data(using: .utf8)!
|
||
let kdfOutput = HKDF<SHA256>.deriveKey(
|
||
inputKeyMaterial: SymmetricKey(data: montanaKey),
|
||
salt: salt,
|
||
outputByteCount: 32
|
||
)
|
||
return kdfOutput
|
||
}
|
||
|
||
/// Save WireGuard private key encrypted with AES-256-GCM (post-quantum protected)
|
||
private func saveEncryptedPrivateKey(_ key: String) -> Bool {
|
||
guard let encryptionKey = deriveEncryptionKey(),
|
||
let plaintext = key.data(using: .utf8) else { return false }
|
||
|
||
do {
|
||
// Generate random nonce
|
||
let nonce = try AES.GCM.Nonce()
|
||
|
||
// Encrypt with AES-256-GCM (AEAD)
|
||
let sealedBox = try AES.GCM.seal(plaintext, using: encryptionKey, nonce: nonce)
|
||
|
||
// Combine nonce + ciphertext + tag
|
||
guard let combined = sealedBox.combined else { return false }
|
||
|
||
// Write encrypted data to disk
|
||
try combined.write(to: URL(fileURLWithPath: privateKeyPath), options: .atomic)
|
||
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: privateKeyPath)
|
||
|
||
return true
|
||
} catch {
|
||
#if DEBUG
|
||
print("Encryption failed: \(error)")
|
||
#endif
|
||
return false
|
||
}
|
||
}
|
||
|
||
/// Load and decrypt WireGuard private key (post-quantum protected)
|
||
private func loadEncryptedPrivateKey() -> String? {
|
||
guard let encryptionKey = deriveEncryptionKey(),
|
||
let encryptedData = try? Data(contentsOf: URL(fileURLWithPath: privateKeyPath)) else {
|
||
return nil
|
||
}
|
||
|
||
do {
|
||
// Decrypt AES-256-GCM
|
||
let sealedBox = try AES.GCM.SealedBox(combined: encryptedData)
|
||
var decrypted = try AES.GCM.open(sealedBox, using: encryptionKey)
|
||
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
// SECURITY: Secure Memory Wiping
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
// Zero out decrypted key material after converting to string
|
||
defer {
|
||
decrypted.withUnsafeMutableBytes { ptr in
|
||
guard let baseAddress = ptr.baseAddress else { return }
|
||
memset_s(baseAddress, ptr.count, 0, ptr.count)
|
||
}
|
||
}
|
||
|
||
return String(data: decrypted, encoding: .utf8)
|
||
} catch {
|
||
#if DEBUG
|
||
print("Decryption failed: \(error)")
|
||
#endif
|
||
return nil
|
||
}
|
||
}
|
||
|
||
// MARK: - Connect / Disconnect
|
||
|
||
func toggle() {
|
||
if isConnected || isConnecting {
|
||
disconnect()
|
||
} else {
|
||
connect()
|
||
}
|
||
}
|
||
|
||
func connect() {
|
||
guard !isConnected, !isConnecting else { return }
|
||
isConnecting = true
|
||
connectionError = nil
|
||
|
||
// Load encrypted private key from disk
|
||
guard let privateKey = loadEncryptedPrivateKey() else {
|
||
isConnecting = false
|
||
connectionError = "Montana VPN не настроен. Перезапустите приложение."
|
||
return
|
||
}
|
||
|
||
// Get next available IP from server
|
||
Task {
|
||
let clientIP = await registerPeerOnServer()
|
||
guard !clientIP.isEmpty else {
|
||
await MainActor.run {
|
||
isConnecting = false
|
||
connectionError = "Не удалось зарегистрировать peer на сервере"
|
||
}
|
||
return
|
||
}
|
||
|
||
// Generate config
|
||
let config = generateWireGuardConfig(privateKey: privateKey, clientIP: clientIP)
|
||
do {
|
||
try config.write(toFile: configPath, atomically: true, encoding: .utf8)
|
||
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: configPath)
|
||
} catch {
|
||
await MainActor.run {
|
||
isConnecting = false
|
||
connectionError = "Ошибка записи конфига: \(error.localizedDescription)"
|
||
}
|
||
return
|
||
}
|
||
|
||
// Start WireGuard tunnel
|
||
await startWireGuardTunnel()
|
||
}
|
||
}
|
||
|
||
func disconnect() {
|
||
wgProcess?.terminate()
|
||
wgProcess = nil
|
||
|
||
// Stop tunnel via wg-quick
|
||
let path = configPath
|
||
DispatchQueue.global().async {
|
||
_ = Self.runProcess("\(Self.homebrewPrefix)/bin/wg-quick", args: ["down", path], sudo: true)
|
||
}
|
||
|
||
isConnecting = false
|
||
isConnected = false
|
||
vpnIP = ""
|
||
pingMs = 0
|
||
bytesIn = 0
|
||
bytesOut = 0
|
||
sessionStart = nil
|
||
stopSessionTimer()
|
||
UserDefaults.standard.set(false, forKey: "vpnRelayActive")
|
||
}
|
||
|
||
// MARK: - Server Registration
|
||
|
||
private func registerPeerOnServer() async -> String {
|
||
// API endpoint to register peer
|
||
guard let url = URL(string: "https://efir.org/api/v1/vpn/register") else { return "" }
|
||
|
||
var request = URLRequest(url: url)
|
||
request.httpMethod = "POST"
|
||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||
|
||
let body: [String: String] = ["public_key": publicKey]
|
||
request.httpBody = try? JSONSerialization.data(withJSONObject: body)
|
||
|
||
do {
|
||
// macOS 12+ async API
|
||
let (data, response) = try await URLSession.shared.data(for: request)
|
||
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
|
||
return ""
|
||
}
|
||
|
||
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||
let clientIP = json["client_ip"] as? String {
|
||
return clientIP
|
||
}
|
||
} catch {
|
||
#if DEBUG
|
||
print("Register peer failed: \(error)")
|
||
#endif
|
||
}
|
||
|
||
return ""
|
||
}
|
||
|
||
// MARK: - WireGuard Tunnel
|
||
|
||
private func startWireGuardTunnel() async {
|
||
// Try wg-quick up with sudo
|
||
let path = configPath
|
||
let iface = interfaceName
|
||
let success = await withCheckedContinuation { continuation in
|
||
DispatchQueue.global().async {
|
||
let output = Self.runProcess("\(Self.homebrewPrefix)/bin/wg-quick", args: ["up", path], sudo: true)
|
||
let success = output.contains("interface:") || output.contains(iface)
|
||
continuation.resume(returning: success)
|
||
}
|
||
}
|
||
|
||
await MainActor.run {
|
||
if success {
|
||
isConnected = true
|
||
isConnecting = false
|
||
sessionStart = Date()
|
||
startSessionTimer()
|
||
checkVPNStatus()
|
||
} else {
|
||
isConnecting = false
|
||
connectionError = "WireGuard не смог запустить туннель — проверьте пароль sudo"
|
||
needsSudo = true
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Config Generation
|
||
|
||
private func generateWireGuardConfig(privateKey: String, clientIP: String) -> String {
|
||
return """
|
||
[Interface]
|
||
PrivateKey = \(privateKey)
|
||
Address = \(clientIP)/24
|
||
DNS = 1.1.1.1, 8.8.8.8
|
||
|
||
[Peer]
|
||
PublicKey = \(serverPublicKey)
|
||
Endpoint = \(serverAddress):\(serverPort)
|
||
AllowedIPs = 0.0.0.0/0, ::/0
|
||
PersistentKeepalive = 25
|
||
"""
|
||
}
|
||
|
||
// MARK: - Status Monitoring
|
||
|
||
private func startPathMonitor() {
|
||
pathMonitor?.cancel()
|
||
pathMonitor = NWPathMonitor()
|
||
pathMonitor?.pathUpdateHandler = { [weak self] path in
|
||
Task { @MainActor in
|
||
guard let self else { return }
|
||
if path.status != .satisfied && self.isConnected {
|
||
self.isConnected = false
|
||
self.vpnIP = ""
|
||
self.pingMs = 0
|
||
self.bytesIn = 0
|
||
self.bytesOut = 0
|
||
self.sessionStart = nil
|
||
self.sessionTimer?.invalidate()
|
||
self.sessionTimer = nil
|
||
}
|
||
self.checkVPNStatus()
|
||
}
|
||
}
|
||
pathMonitor?.start(queue: DispatchQueue(label: "network.montana.vpn.monitor"))
|
||
}
|
||
|
||
private func startStatusPolling() {
|
||
checkTimer?.invalidate()
|
||
checkTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in
|
||
Task { @MainActor in
|
||
self?.checkVPNStatus()
|
||
}
|
||
}
|
||
}
|
||
|
||
func checkVPNStatus() {
|
||
let iface = interfaceName
|
||
let sub = subnet
|
||
DispatchQueue.global().async { [weak self] in
|
||
guard let self else { return }
|
||
|
||
// Check if interface exists
|
||
let ifOutput = Self.runProcess("/sbin/ifconfig", args: [iface])
|
||
let connected = ifOutput.contains(sub)
|
||
|
||
var detectedIP = ""
|
||
var inBytes: Int64 = 0
|
||
var outBytes: Int64 = 0
|
||
|
||
if connected {
|
||
detectedIP = self.extractIP(from: ifOutput)
|
||
|
||
// Get WireGuard stats
|
||
let wgOutput = Self.runProcess("\(Self.homebrewPrefix)/bin/wg", args: ["show", iface, "transfer"])
|
||
if let (rx, tx) = self.parseWireGuardStats(wgOutput) {
|
||
inBytes = rx
|
||
outBytes = tx
|
||
}
|
||
|
||
// Measure ping
|
||
self.measurePing()
|
||
}
|
||
|
||
Task { @MainActor in
|
||
let wasConnected = self.isConnected
|
||
self.isConnected = connected
|
||
self.isConnecting = false
|
||
|
||
if connected {
|
||
self.vpnIP = detectedIP
|
||
self.bytesIn = inBytes
|
||
self.bytesOut = outBytes
|
||
self.connectionError = nil
|
||
if !wasConnected {
|
||
self.sessionStart = Date()
|
||
self.startSessionTimer()
|
||
}
|
||
} else {
|
||
self.vpnIP = ""
|
||
self.pingMs = 0
|
||
self.bytesIn = 0
|
||
self.bytesOut = 0
|
||
self.sessionStart = nil
|
||
self.stopSessionTimer()
|
||
}
|
||
UserDefaults.standard.set(connected, forKey: "vpnRelayActive")
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Helpers
|
||
|
||
nonisolated private func extractIP(from output: String) -> String {
|
||
for line in output.components(separatedBy: "\n") {
|
||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||
if trimmed.hasPrefix("inet ") && trimmed.contains(subnet) {
|
||
let parts = trimmed.components(separatedBy: " ")
|
||
if parts.count >= 2 { return parts[1] }
|
||
}
|
||
}
|
||
return subnet + ".x"
|
||
}
|
||
|
||
nonisolated private func parseWireGuardStats(_ output: String) -> (Int64, Int64)? {
|
||
let parts = output.trimmingCharacters(in: .whitespacesAndNewlines).components(separatedBy: "\t")
|
||
guard parts.count >= 2,
|
||
let rx = Int64(parts[0]),
|
||
let tx = Int64(parts[1]) else { return nil }
|
||
return (rx, tx)
|
||
}
|
||
|
||
nonisolated private func measurePing() {
|
||
DispatchQueue.global().async { [weak self] in
|
||
let output = Self.runProcess("/sbin/ping", args: ["-c", "1", "-t", "3", "72.56.102.240"])
|
||
if let range = output.range(of: "time=") {
|
||
let after = output[range.upperBound...]
|
||
if let spaceIdx = after.firstIndex(of: " ") {
|
||
let msStr = String(after[..<spaceIdx])
|
||
let ms = Int(Double(msStr) ?? 0)
|
||
Task { @MainActor in
|
||
self?.pingMs = ms
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func startSessionTimer() {
|
||
sessionTimer?.invalidate()
|
||
sessionTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
|
||
Task { @MainActor in
|
||
self?.objectWillChange.send()
|
||
}
|
||
}
|
||
}
|
||
|
||
private func stopSessionTimer() {
|
||
sessionTimer?.invalidate()
|
||
sessionTimer = nil
|
||
}
|
||
|
||
// MARK: - Process Execution
|
||
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
// SECURITY: Path Whitelist
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
// Only allow execution of trusted system binaries and WireGuard tools
|
||
nonisolated private static var allowedExecutablePaths: Set<String> {
|
||
return [
|
||
"/sbin/ifconfig",
|
||
"/sbin/ping",
|
||
"/usr/bin/osascript",
|
||
"\(homebrewPrefix)/bin/wg",
|
||
"\(homebrewPrefix)/bin/wg-quick"
|
||
]
|
||
}
|
||
|
||
nonisolated private static func escapeShellArg(_ arg: String) -> String {
|
||
// Escape single quotes: ' -> '\''
|
||
return "'\(arg.replacingOccurrences(of: "'", with: "'\\''"))'"
|
||
}
|
||
|
||
nonisolated private static func runProcess(_ path: String, args: [String] = [], sudo: Bool = false) -> String {
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
// SECURITY: Command Injection Prevention
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
// 1. Validate executable path against whitelist
|
||
guard allowedExecutablePaths.contains(path) else {
|
||
#if DEBUG
|
||
print("Security: Blocked execution of non-whitelisted path: \(path)")
|
||
#endif
|
||
return ""
|
||
}
|
||
|
||
// 2. Validate total argument length (prevent buffer overflow)
|
||
let totalArgsLength = args.joined().count
|
||
guard totalArgsLength < 1024 else {
|
||
#if DEBUG
|
||
print("Security: Blocked execution with excessive argument length: \(totalArgsLength)")
|
||
#endif
|
||
return ""
|
||
}
|
||
if sudo {
|
||
// Use osascript to request sudo via GUI (with proper escaping)
|
||
let escapedPath = escapeShellArg(path)
|
||
let escapedArgs = args.map { escapeShellArg($0) }.joined(separator: " ")
|
||
let command = "\(escapedPath) \(escapedArgs)"
|
||
let script = """
|
||
do shell script \(escapeShellArg(command)) with administrator privileges
|
||
"""
|
||
let task = Process()
|
||
task.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
|
||
task.arguments = ["-e", script]
|
||
let pipe = Pipe()
|
||
task.standardOutput = pipe
|
||
task.standardError = pipe
|
||
do {
|
||
try task.run()
|
||
task.waitUntilExit()
|
||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||
return String(data: data, encoding: .utf8) ?? ""
|
||
} catch {
|
||
return ""
|
||
}
|
||
} else {
|
||
let task = Process()
|
||
task.executableURL = URL(fileURLWithPath: path)
|
||
task.arguments = args
|
||
let pipe = Pipe()
|
||
task.standardOutput = pipe
|
||
task.standardError = Pipe()
|
||
do {
|
||
try task.run()
|
||
task.waitUntilExit()
|
||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||
return String(data: data, encoding: .utf8) ?? ""
|
||
} catch {
|
||
return ""
|
||
}
|
||
}
|
||
}
|
||
|
||
static func formatBytes(_ bytes: Int64) -> String {
|
||
if bytes < 1024 { return "\(bytes) B" }
|
||
let kb = Double(bytes) / 1024.0
|
||
if kb < 1024 { return String(format: "%.1f KB", kb) }
|
||
let mb = kb / 1024.0
|
||
if mb < 1024 { return String(format: "%.1f MB", mb) }
|
||
let gb = mb / 1024.0
|
||
return String(format: "%.2f GB", gb)
|
||
}
|
||
|
||
// MARK: - Settings Helpers
|
||
|
||
func openVPNSettings() {
|
||
// Open Montana VPN config folder
|
||
NSWorkspace.shared.selectFile(configPath, inFileViewerRootedAtPath: configDir.path)
|
||
}
|
||
}
|