import NetworkExtension import MobileNebula import SwiftyJSON import os.log var log = Logger(subsystem: "net.defined.mobileNebula", category: "Site") extension String: Error {} enum IPCResponseType: String, Codable { case error = "error" case success = "success" } class IPCResponse: Codable { var type: IPCResponseType //TODO: change message to data? var message: JSON? init(type: IPCResponseType, message: JSON?) { self.type = type self.message = message } } class IPCRequest: Codable { var command: String var arguments: JSON? init(command: String, arguments: JSON?) { self.command = command self.arguments = arguments } init(command: String) { self.command = command } } struct CertificateInfo: Codable { var cert: Certificate var rawCert: String var validity: CertificateValidity enum CodingKeys: String, CodingKey { case cert = "Cert" case rawCert = "RawCert" case validity = "Validity" } } struct Certificate: Codable { var fingerprint: String var signature: String var details: CertificateDetails /// An empty initializer to make error reporting easier init() { fingerprint = "" signature = "" details = CertificateDetails() } } struct CertificateDetails: Codable { var name: String var notBefore: String var notAfter: String var publicKey: String var groups: [String] var ips: [String] var subnets: [String] var isCa: Bool var issuer: String /// An empty initializer to make error reporting easier init() { name = "" notBefore = "" notAfter = "" publicKey = "" groups = [] ips = ["ERROR"] subnets = [] isCa = false issuer = "" } } struct CertificateValidity: Codable { var valid: Bool var reason: String enum CodingKeys: String, CodingKey { case valid = "Valid" case reason = "Reason" } } let statusMap: Dictionary = [ NEVPNStatus.invalid: false, NEVPNStatus.disconnected: false, NEVPNStatus.connecting: false, NEVPNStatus.connected: true, NEVPNStatus.reasserting: true, NEVPNStatus.disconnecting: true, ] let statusString: Dictionary = [ NEVPNStatus.invalid: "Invalid configuration", NEVPNStatus.disconnected: "Disconnected", NEVPNStatus.connecting: "Connecting...", NEVPNStatus.connected: "Connected", NEVPNStatus.reasserting: "Reasserting...", NEVPNStatus.disconnecting: "Disconnecting...", ] // Represents a site that was pulled out of the system configuration class Site: Codable { // Stored in manager var name: String var id: String // Stored in proto var staticHostmap: Dictionary var unsafeRoutes: [UnsafeRoute] var cert: CertificateInfo? var ca: [CertificateInfo] var lhDuration: Int var port: Int var mtu: Int var cipher: String var sortKey: Int var logVerbosity: String var connected: Bool? //TODO: active is a better name var status: String? var logFile: String? var managed: Bool // The following fields are present if managed = true var lastManagedUpdate: String? var rawConfig: String? /// If true then this site needs to be migrated to the filesystem. Should be handled by the initiator of the site var needsToMigrateToFS: Bool = false // A list of error encountered when trying to rehydrate a site from config var errors: [String] var manager: NETunnelProviderManager? var incomingSite: IncomingSite? /// Creates a new site from a vpn manager instance. Mainly used by the UI. A manager is required to be able to edit the system profile convenience init(manager: NETunnelProviderManager) throws { //TODO: Throw an error and have Sites delete the site, notify the user instead of using ! let proto = manager.protocolConfiguration as! NETunnelProviderProtocol try self.init(proto: proto) self.manager = manager self.connected = statusMap[manager.connection.status] self.status = statusString[manager.connection.status] } convenience init(proto: NETunnelProviderProtocol) throws { let dict = proto.providerConfiguration if dict?["config"] != nil { let config = dict?["config"] as? Data ?? Data() let decoder = JSONDecoder() let incoming = try decoder.decode(IncomingSite.self, from: config) self.init(incoming: incoming) self.needsToMigrateToFS = true return } let id = dict?["id"] as? String ?? nil if id == nil { throw("Non-conforming site \(String(describing: dict))") } try self.init(path: SiteList.getSiteConfigFile(id: id!, createDir: false)) } /// Creates a new site from a path on the filesystem. Mainly ussed by the VPN process or when in simulator where we lack a NEVPNManager convenience init(path: URL) throws { let config = try Data(contentsOf: path) let decoder = JSONDecoder() let incoming = try decoder.decode(IncomingSite.self, from: config) self.init(incoming: incoming) } init(incoming: IncomingSite) { var err: NSError? incomingSite = incoming errors = [] name = incoming.name id = incoming.id staticHostmap = incoming.staticHostmap unsafeRoutes = incoming.unsafeRoutes ?? [] lhDuration = incoming.lhDuration port = incoming.port cipher = incoming.cipher sortKey = incoming.sortKey ?? 0 logVerbosity = incoming.logVerbosity ?? "info" mtu = incoming.mtu ?? 1300 managed = incoming.managed ?? false lastManagedUpdate = incoming.lastManagedUpdate rawConfig = incoming.rawConfig do { let rawCert = incoming.cert let rawDetails = MobileNebulaParseCerts(rawCert, &err) if (err != nil) { throw err! } var certs: [CertificateInfo] certs = try JSONDecoder().decode([CertificateInfo].self, from: rawDetails.data(using: .utf8)!) if (certs.count == 0) { throw "No certificate found" } cert = certs[0] if (!cert!.validity.valid) { errors.append("Certificate is invalid: \(cert!.validity.reason)") } } catch { errors.append("Error while loading certificate: \(error.localizedDescription)") } do { let rawCa = incoming.ca let rawCaDetails = MobileNebulaParseCerts(rawCa, &err) if (err != nil) { throw err! } ca = try JSONDecoder().decode([CertificateInfo].self, from: rawCaDetails.data(using: .utf8)!) var hasErrors = false ca.forEach { cert in if (!cert.validity.valid) { hasErrors = true } } if (hasErrors && !managed) { errors.append("There are issues with 1 or more ca certificates") } } catch { ca = [] errors.append("Error while loading certificate authorities: \(error.localizedDescription)") } do { logFile = try SiteList.getSiteLogFile(id: self.id, createDir: true).path } catch { logFile = nil errors.append("Unable to create the site directory: \(error.localizedDescription)") } if (managed && (try? getDNCredentials())?.invalid != false) { errors.append("Unable to fetch managed updates - please re-enroll the device") } if (errors.isEmpty) { do { let encoder = JSONEncoder() let rawConfig = try encoder.encode(incoming) let key = try getKey() let strConfig = String(data: rawConfig, encoding: .utf8) var err: NSError? MobileNebulaTestConfig(strConfig, key, &err) if (err != nil) { throw err! } } catch { errors.append("Config test error: \(error.localizedDescription)") } } } // Gets the private key from the keystore, we don't always need it in memory func getKey() throws -> String { guard let keyData = KeyChain.load(key: "\(id).key") else { throw "failed to get key from keychain" } //TODO: make sure this is valid on return! return String(decoding: keyData, as: UTF8.self) } func getDNCredentials() throws -> DNCredentials { if (!managed) { throw "unmanaged site has no dn credentials" } let rawDNCredentials = KeyChain.load(key: "\(id).dnCredentials") if rawDNCredentials == nil { throw "failed to find dn credentials in keychain" } let decoder = JSONDecoder() return try decoder.decode(DNCredentials.self, from: rawDNCredentials!) } func invalidateDNCredentials() throws { let creds = try getDNCredentials() creds.invalid = true if (!(try creds.save(siteID: self.id))) { throw "failed to store dn credentials in keychain" } } func validateDNCredentials() throws { let creds = try getDNCredentials() creds.invalid = false if (!(try creds.save(siteID: self.id))) { throw "failed to store dn credentials in keychain" } } func getConfig() throws -> Data { return try self.incomingSite!.getConfig() } // Limits what we export to the UI private enum CodingKeys: String, CodingKey { case name case id case staticHostmap case cert case ca case lhDuration case port case cipher case sortKey case connected case status case logFile case unsafeRoutes case logVerbosity case errors case mtu case managed case lastManagedUpdate case rawConfig } } class StaticHosts: Codable { var lighthouse: Bool var destinations: [String] } class UnsafeRoute: Codable { var route: String var via: String var mtu: Int? } class DNCredentials: Codable { var hostID: String var privateKey: String var counter: Int var trustedKeys: String var invalid: Bool { get { return _invalid ?? false } set { _invalid = newValue } } private var _invalid: Bool? func save(siteID: String) throws -> Bool { let encoder = JSONEncoder() let rawDNCredentials = try encoder.encode(self) return KeyChain.save(key: "\(siteID).dnCredentials", data: rawDNCredentials, managed: true) } enum CodingKeys: String, CodingKey { case hostID case privateKey case counter case trustedKeys case _invalid = "invalid" } } // This class represents a site coming in from flutter, meant only to be saved and re-loaded as a proper Site struct IncomingSite: Codable { var name: String var id: String var staticHostmap: Dictionary var unsafeRoutes: [UnsafeRoute]? var cert: String var ca: String var lhDuration: Int var port: Int var mtu: Int? var cipher: String var sortKey: Int? var logVerbosity: String? var key: String? var managed: Bool? // The following fields are present if managed = true var dnCredentials: DNCredentials? var lastManagedUpdate: String? var rawConfig: String? func getConfig() throws -> Data { let encoder = JSONEncoder() var config = self config.key = nil config.dnCredentials = nil return try encoder.encode(config) } func save(manager: NETunnelProviderManager?, saveToManager: Bool = true, callback: @escaping (Error?) -> ()) { let configPath: URL do { configPath = try SiteList.getSiteConfigFile(id: self.id, createDir: true) } catch { callback(error) return } log.notice("Saving to \(configPath, privacy: .public)") do { if (self.key != nil) { let data = self.key!.data(using: .utf8) if (!KeyChain.save(key: "\(self.id).key", data: data!, managed: self.managed ?? false)) { return callback("failed to store key material in keychain") } } do { if ((try self.dnCredentials?.save(siteID: self.id)) == false) { return callback("failed to store dn credentials in keychain") } } catch { return callback(error) } try self.getConfig().write(to: configPath) } catch { return callback(error) } #if targetEnvironment(simulator) // We are on a simulator and there is no NEVPNManager for us to interact with callback(nil) #else if saveToManager { self.saveToManager(manager: manager, callback: callback) } else { callback(nil) } #endif } private func saveToManager(manager: NETunnelProviderManager?, callback: @escaping (Error?) -> ()) { if (manager != nil) { // We need to refresh our settings to properly update config manager?.loadFromPreferences { error in if (error != nil) { return callback(error) } return self.finishSaveToManager(manager: manager!, callback: callback) } return } return finishSaveToManager(manager: NETunnelProviderManager(), callback: callback) } private func finishSaveToManager(manager: NETunnelProviderManager, callback: @escaping (Error?) -> ()) { // Stuff our details in the protocol let proto = manager.protocolConfiguration as? NETunnelProviderProtocol ?? NETunnelProviderProtocol() proto.providerConfiguration = ["id": self.id] proto.serverAddress = "Nebula" // Finish up the manager, this is what stores everything at the system level manager.protocolConfiguration = proto //TODO: cert name? manager.protocolConfiguration?.username //TODO: This is what is shown on the vpn page. We should add more identifying details in manager.localizedDescription = self.name manager.isEnabled = true manager.saveToPreferences{ error in return callback(error) } } }