montana/macOS/MontanaPresence/VPNManager.swift

636 lines
23 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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.

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)
}
}