Procházet zdrojové kódy

remote: support SPICE TLS

osy před 1 rokem
rodič
revize
ea958e66d4

+ 29 - 4
Configuration/UTMQemuConfiguration+Arguments.swift

@@ -71,6 +71,16 @@ import Virtualization // for getting network interfaces
         socketURL.appendingPathComponent(information.uuid.uuidString).appendingPathExtension("qga")
     }
 
+    /// Used only if in remote sever mode.
+    var spiceTlsKeyUrl: URL {
+        socketURL.appendingPathComponent(information.uuid.uuidString).appendingPathExtension("pem")
+    }
+
+    /// Used only if in remote sever mode.
+    var spiceTlsCertUrl: URL {
+        socketURL.appendingPathComponent(information.uuid.uuidString).appendingPathExtension("crt")
+    }
+
     /// Combined generated and user specified arguments.
     @QEMUArgumentBuilder var allArguments: [QEMUArgument] {
         generatedArguments
@@ -120,15 +130,30 @@ import Virtualization // for getting network interfaces
     @QEMUArgumentBuilder private var spiceArguments: [QEMUArgument] {
         f("-spice")
         if let port = qemu.spiceServerPort {
-            "port=\(port)"
+            if qemu.isSpiceServerTlsEnabled {
+                "tls-port=\(port)"
+                "tls-channel=default"
+                "x509-key-file="
+                spiceTlsKeyUrl
+                "x509-cert-file="
+                spiceTlsCertUrl
+                "x509-cacert-file="
+                spiceTlsCertUrl
+            } else {
+                "port=\(port)"
+            }
         } else {
             "unix=on"
             "addr=\(spiceSocketURL.lastPathComponent)"
         }
         "disable-ticketing=on"
-        "image-compression=off"
-        "playback-compression=off"
-        "streaming-video=off"
+        if !isRemoteSpice {
+            "image-compression=off"
+            "playback-compression=off"
+            "streaming-video=off"
+        } else {
+            "streaming-video=filter"
+        }
         "gl=\(isGLSupported && !isRemoteSpice ? "on" : "off")"
         f()
         f("-chardev")

+ 6 - 0
Configuration/UTMQemuConfigurationQEMU.swift

@@ -72,6 +72,12 @@ struct UTMQemuConfigurationQEMU: Codable {
     /// Set to open a port for remote SPICE session. Not saved.
     var spiceServerPort: UInt16?
 
+    /// If true, all SPICE channels will be over TLS. Not saved.
+    var isSpiceServerTlsEnabled: Bool = false
+
+    /// Set to TLS public key for SPICE server in SubjectPublicKey. Not saved.
+    var spiceServerPublicKey: Data?
+
     enum CodingKeys: String, CodingKey {
         case hasDebugLog = "DebugLog"
         case hasUefiBoot = "UEFIBoot"

+ 2 - 2
Platform/macOS/UTMDataExtension.swift

@@ -82,7 +82,7 @@ extension UTMData {
     ///   - options: Start options
     ///   - server: Remote server
     /// - Returns: Port number to SPICE server
-    func startRemote(vm: VMData, options: UTMVirtualMachineStartOptions, forClient client: UTMRemoteServer.Remote) async throws -> UInt16 {
+    func startRemote(vm: VMData, options: UTMVirtualMachineStartOptions, forClient client: UTMRemoteServer.Remote) async throws -> (port: UInt16, publicKey: Data) {
         guard let wrapped = vm.wrapped as? UTMQemuVirtualMachine, type(of: wrapped).capabilities.supportsRemoteSession else {
             throw UTMDataError.unsupportedBackend
         }
@@ -94,7 +94,7 @@ extension UTMData {
         }
         try await wrapped.start(options: options.union(.remoteSession))
         vmWindows[vm] = session
-        return wrapped.config.qemu.spiceServerPort!
+        return (wrapped.config.qemu.spiceServerPort!, wrapped.config.qemu.spiceServerPublicKey!)
     }
 
     func stop(vm: VMData) {

+ 111 - 22
Remote/GenerateKey.c

@@ -132,7 +132,95 @@ err:
     return 0;
 }
 
-_Nullable CFDataRef GenerateRSACertificate(CFStringRef _Nonnull commonName, CFStringRef _Nonnull organizationName, CFNumberRef _Nullable serial, CFNumberRef _Nullable days, CFBooleanRef _Nonnull isClient) {
+static _Nullable CFDataRef CreateP12FromKey(EVP_PKEY *pkey, X509 *cert) {
+    PKCS12 *p12;
+    BIO *mem;
+    char *ptr;
+    long length;
+    CFDataRef data;
+
+    p12 = PKCS12_create("password", NULL, pkey, cert, NULL, NID_pbe_WithSHA1And3_Key_TripleDES_CBC, NID_pbe_WithSHA1And40BitRC2_CBC, PKCS12_DEFAULT_ITER, 1, 0);
+    if (!p12) {
+        ERR_print_errors_fp(stderr);
+        return NULL;
+    }
+    mem = BIO_new(BIO_s_mem());
+    if (!mem || !i2d_PKCS12_bio(mem, p12)) {
+        ERR_print_errors_fp(stderr);
+        PKCS12_free(p12);
+        BIO_free(mem);
+        return NULL;
+    }
+    PKCS12_free(p12);
+    length = BIO_get_mem_data(mem, &ptr);
+    data = CFDataCreate(kCFAllocatorDefault, (void *)ptr, length);
+    BIO_free(mem);
+    return data;
+}
+
+static _Nullable CFDataRef CreatePrivatePEMFromKey(EVP_PKEY *pkey) {
+    BIO *mem;
+    char *ptr;
+    long length;
+    CFDataRef data;
+
+    mem = BIO_new(BIO_s_mem());
+    if (!mem || !PEM_write_bio_PrivateKey(mem, pkey, NULL, NULL, 0, NULL, NULL)) {
+        ERR_print_errors_fp(stderr);
+        BIO_free(mem);
+        return NULL;
+    }
+    length = BIO_get_mem_data(mem, &ptr);
+    data = CFDataCreate(kCFAllocatorDefault, (void *)ptr, length);
+    BIO_free(mem);
+    return data;
+}
+
+static _Nullable CFDataRef CreatePublicPEMFromCert(X509 *cert) {
+    BIO *mem;
+    char *ptr;
+    long length;
+    CFDataRef data;
+
+    mem = BIO_new(BIO_s_mem());
+    if (!mem || !PEM_write_bio_X509(mem, cert)) {
+        ERR_print_errors_fp(stderr);
+        BIO_free(mem);
+        return NULL;
+    }
+    length = BIO_get_mem_data(mem, &ptr);
+    data = CFDataCreate(kCFAllocatorDefault, (void *)ptr, length);
+    BIO_free(mem);
+    return data;
+}
+
+static _Nullable CFDataRef CreatePublicKeyFromCert(X509 *cert) {
+    EVP_PKEY* pubkey;
+    BIO *mem;
+    char *ptr;
+    long length;
+    CFDataRef data;
+
+    pubkey = X509_get_pubkey(cert);
+    if (!pubkey) {
+        ERR_print_errors_fp(stderr);
+        return NULL;
+    }
+    mem = BIO_new(BIO_s_mem());
+    if (!mem || !i2d_PUBKEY_bio(mem, pubkey)) {
+        ERR_print_errors_fp(stderr);
+        EVP_PKEY_free(pubkey);
+        BIO_free(mem);
+        return NULL;
+    }
+    length = BIO_get_mem_data(mem, &ptr);
+    data = CFDataCreate(kCFAllocatorDefault, (void *)ptr, length);
+    BIO_free(mem);
+    EVP_PKEY_free(pubkey);
+    return data;
+}
+
+_Nullable CFArrayRef GenerateRSACertificate(CFStringRef _Nonnull commonName, CFStringRef _Nonnull organizationName, CFNumberRef _Nullable serial, CFNumberRef _Nullable days, CFBooleanRef _Nonnull isClient) {
     char _commonName[X509_ENTRY_MAX_LENGTH];
     char _organizationName[X509_ENTRY_MAX_LENGTH];
     long _serial = 0;
@@ -140,11 +228,8 @@ _Nullable CFDataRef GenerateRSACertificate(CFStringRef _Nonnull commonName, CFSt
     int _isClient = 0;
     X509 *cert;
     EVP_PKEY *pkey;
-    PKCS12 *p12;
-    BIO *mem;
-    char *ptr;
-    long length;
-    CFDataRef data;
+    CFDataRef arr[4] = {NULL};
+    CFArrayRef cfarr = NULL;
 
     if (!CFStringGetCString(commonName, _commonName, X509_ENTRY_MAX_LENGTH, kCFStringEncodingUTF8)) {
         return NULL;
@@ -166,22 +251,26 @@ _Nullable CFDataRef GenerateRSACertificate(CFStringRef _Nonnull commonName, CFSt
         ERR_print_errors_fp(stderr);
         return NULL;
     }
-    p12 = PKCS12_create("password", NULL, pkey, cert, NULL, NID_pbe_WithSHA1And3_Key_TripleDES_CBC, NID_pbe_WithSHA1And40BitRC2_CBC, PKCS12_DEFAULT_ITER, 1, 0);
-    EVP_PKEY_free(pkey);
-    X509_free(cert);
-    if (!p12) {
-        ERR_print_errors_fp(stderr);
-        return NULL;
+    arr[0] = CreateP12FromKey(pkey, cert);
+    arr[1] = CreatePrivatePEMFromKey(pkey);
+    arr[2] = CreatePublicPEMFromCert(cert);
+    arr[3] = CreatePublicKeyFromCert(cert);
+    if (arr[0] && arr[1] && arr[2] && arr[3]) {
+        cfarr = CFArrayCreate(kCFAllocatorDefault, (const void **)arr, 4, &kCFTypeArrayCallBacks);
     }
-    mem = BIO_new(BIO_s_mem());
-    if (!mem || !i2d_PKCS12_bio(mem, p12)) {
-        ERR_print_errors_fp(stderr);
-        PKCS12_free(p12);
-        return NULL;
+    if (arr[0]) {
+        CFRelease(arr[0]);
     }
-    PKCS12_free(p12);
-    length = BIO_get_mem_data(mem, &ptr);
-    data = CFDataCreate(kCFAllocatorDefault, (void *)ptr, length);
-    BIO_free(mem);
-    return data;
+    if (arr[1]) {
+        CFRelease(arr[1]);
+    }
+    if (arr[2]) {
+        CFRelease(arr[2]);
+    }
+    if (arr[3]) {
+        CFRelease(arr[3]);
+    }
+    EVP_PKEY_free(pkey);
+    X509_free(cert);
+    return cfarr;
 }

+ 1 - 1
Remote/GenerateKey.h

@@ -28,6 +28,6 @@
 ///   - serial: Serial number of the certificate
 ///   - days: Validity in days from today
 ///   - isClient: If 0 then a TLS Server certificate is generated, otherwise a TLS Client certificate is generated
-_Nullable CFDataRef GenerateRSACertificate(CFStringRef _Nonnull commonName, CFStringRef _Nonnull organizationName, CFNumberRef _Nullable serial, CFNumberRef _Nullable days, CFBooleanRef _Nonnull isClient);
+_Nullable CFArrayRef GenerateRSACertificate(CFStringRef _Nonnull commonName, CFStringRef _Nonnull organizationName, CFNumberRef _Nullable serial, CFNumberRef _Nullable days, CFBooleanRef _Nonnull isClient);
 
 #endif /* GenerateKey_h */

+ 3 - 2
Remote/UTMRemoteClient.swift

@@ -268,8 +268,9 @@ extension UTMRemoteClient {
             return fileUrl
         }
 
-        func startVirtualMachine(id: UUID, options: UTMVirtualMachineStartOptions) async throws -> UInt16 {
-            try await _startVirtualMachine(parameters: .init(id: id, options: options)).spiceServerPort
+        func startVirtualMachine(id: UUID, options: UTMVirtualMachineStartOptions) async throws -> (port: UInt16, publicKey: Data) {
+            let reply = try await _startVirtualMachine(parameters: .init(id: id, options: options))
+            return (reply.spiceServerPort, reply.spiceServerPublicKey)
         }
 
         func stopVirtualMachine(id: UUID, method: UTMVirtualMachineStopMethod) async throws {

+ 2 - 2
Remote/UTMRemoteKeyManager.swift

@@ -49,12 +49,12 @@ class UTMRemoteKeyManager {
         let organizationName = "UTM" as CFString
         let serialNumber = Int.random(in: 1..<CLong.max) as CFNumber
         let days = 3650 as CFNumber
-        guard let p12Data = GenerateRSACertificate(commonName, organizationName, serialNumber, days, isClient as CFBoolean)?.takeUnretainedValue() else {
+        guard let data = GenerateRSACertificate(commonName, organizationName, serialNumber, days, isClient as CFBoolean)?.takeUnretainedValue() as? [CFData] else {
             throw UTMRemoteKeyManagerError.generateKeyFailure
         }
         let importOptions = [ kSecImportExportPassphrase as String: "password" ] as CFDictionary
         var rawItems: CFArray?
-        try withSecurityThrow(SecPKCS12Import(p12Data, importOptions, &rawItems))
+        try withSecurityThrow(SecPKCS12Import(data[0], importOptions, &rawItems))
         guard let items = (rawItems! as! [[String: Any]]).first else {
             throw UTMRemoteKeyManagerError.parseKeyFailure
         }

+ 1 - 0
Remote/UTMRemoteMessage.swift

@@ -129,6 +129,7 @@ extension UTMRemoteMessageServer {
 
         struct Reply: Serializable, Codable {
             let spiceServerPort: UInt16
+            let spiceServerPublicKey: Data
         }
     }
 

+ 2 - 2
Remote/UTMRemoteServer.swift

@@ -631,8 +631,8 @@ extension UTMRemoteServer {
 
         private func _startVirtualMachine(parameters: M.StartVirtualMachine.Request) async throws -> M.StartVirtualMachine.Reply {
             let vm = try await findVM(withId: parameters.id)
-            let port = try await data.startRemote(vm: vm, options: parameters.options, forClient: client)
-            return .init(spiceServerPort: port)
+            let (port, publicKey) = try await data.startRemote(vm: vm, options: parameters.options, forClient: client)
+            return .init(spiceServerPort: port, spiceServerPublicKey: publicKey)
         }
 
         private func _stopVirtualMachine(parameters: M.StopVirtualMachine.Request) async throws -> M.StopVirtualMachine.Reply {

+ 2 - 2
Remote/UTMRemoteSpiceVirtualMachine.swift

@@ -154,7 +154,7 @@ extension UTMRemoteSpiceVirtualMachine {
 extension UTMRemoteSpiceVirtualMachine {
     func start(options: UTMVirtualMachineStartOptions) async throws {
         try await _state.operation(before: .stopped, during: .starting, after: .started) {
-            let port = try await server.startVirtualMachine(id: id, options: options)
+            let spiceServer = try await server.startVirtualMachine(id: id, options: options)
             var options = UTMSpiceIOOptions()
             if await !config.sound.isEmpty {
                 options.insert(.hasAudio)
@@ -170,7 +170,7 @@ extension UTMRemoteSpiceVirtualMachine {
                 options.insert(.hasDebugLog)
             }
             #endif
-            let ioService = UTMSpiceIO(host: server.host, port: Int(port), options: options)
+            let ioService = UTMSpiceIO(host: server.host, tlsPort: Int(spiceServer.port), serverPublicKey: spiceServer.publicKey, options: options)
             ioService.logHandler = { (line: String) -> Void in
                 guard !line.contains("spice_make_scancode") else {
                     return // do not log key presses for privacy reasons

+ 17 - 0
Services/UTMQemuVirtualMachine.swift

@@ -279,6 +279,7 @@ extension UTMQemuVirtualMachine {
         await MainActor.run {
             config.qemu.isDisposable = isRunningAsDisposible
             config.qemu.spiceServerPort = spicePort
+            config.qemu.isSpiceServerTlsEnabled = true
         }
 
         // start TPM
@@ -338,6 +339,19 @@ extension UTMQemuVirtualMachine {
             }
             try pipeInterface.start()
             interface = pipeInterface
+            // generate a TLS key for this session
+            guard let key = GenerateRSACertificate("UTM Remote SPICE Server" as CFString,
+                                                   "UTM" as CFString,
+                                                   Int.random(in: 1..<CLong.max) as CFNumber,
+                                                   1 as CFNumber,
+                                                   false as CFBoolean)?.takeUnretainedValue() as? [Data] else {
+                throw UTMQemuVirtualMachineError.keyGenerationFailed
+            }
+            try await key[1].write(to: config.spiceTlsKeyUrl)
+            try await key[2].write(to: config.spiceTlsCertUrl)
+            await MainActor.run {
+                config.qemu.spiceServerPublicKey = key[3]
+            }
         } else {
             let ioService = UTMSpiceIO(socketUrl: spiceSocketUrl, options: options)
             ioService.logHandler = { [weak system] (line: String) -> Void in
@@ -835,6 +849,7 @@ enum UTMQemuVirtualMachineError: Error {
     case accessShareFailed
     case invalidVmState
     case saveSnapshotFailed(Error)
+    case keyGenerationFailed
 }
 
 extension UTMQemuVirtualMachineError: LocalizedError {
@@ -851,6 +866,8 @@ extension UTMQemuVirtualMachineError: LocalizedError {
         case .invalidVmState: return NSLocalizedString("The virtual machine is in an invalid state.", comment: "UTMQemuVirtualMachine")
         case .saveSnapshotFailed(let error):
             return String.localizedStringWithFormat(NSLocalizedString("Failed to save VM snapshot. Usually this means at least one device does not support snapshots. %@", comment: "UTMQemuVirtualMachine"), error.localizedDescription)
+        case .keyGenerationFailed:
+            return NSLocalizedString("Failed to generate TLS key for server.", comment: "UTMQemuVirtualMachine")
         }
     }
 }

+ 1 - 1
Services/UTMSpiceIO.h

@@ -58,7 +58,7 @@ NS_ASSUME_NONNULL_BEGIN
 
 - (instancetype)init NS_UNAVAILABLE;
 - (instancetype)initWithSocketUrl:(NSURL *)socketUrl options:(UTMSpiceIOOptions)options NS_DESIGNATED_INITIALIZER;
-- (instancetype)initWithHost:(NSString *)host port:(NSInteger)port options:(UTMSpiceIOOptions)options NS_DESIGNATED_INITIALIZER;
+- (instancetype)initWithHost:(NSString *)host tlsPort:(NSInteger)tlsPort serverPublicKey:(NSData *)serverPublicKey options:(UTMSpiceIOOptions)options NS_DESIGNATED_INITIALIZER;
 - (void)changeSharedDirectory:(NSURL *)url;
 
 - (BOOL)startWithError:(NSError * _Nullable *)error;

+ 6 - 4
Services/UTMSpiceIO.m

@@ -24,7 +24,8 @@ NSString *const kUTMErrorDomain = @"com.utmapp.utm";
 
 @property (nonatomic, nullable) NSURL *socketUrl;
 @property (nonatomic, nullable) NSString *host;
-@property (nonatomic) NSInteger port;
+@property (nonatomic) NSInteger tlsPort;
+@property (nonatomic, nullable) NSData *serverPublicKey;
 @property (nonatomic) UTMSpiceIOOptions options;
 @property (nonatomic, readwrite, nullable) CSDisplay *primaryDisplay;
 @property (nonatomic) NSMutableArray<CSDisplay *> *mutableDisplays;
@@ -73,10 +74,11 @@ NSString *const kUTMErrorDomain = @"com.utmapp.utm";
     return self;
 }
 
-- (instancetype)initWithHost:(NSString *)host port:(NSInteger)port options:(UTMSpiceIOOptions)options {
+- (instancetype)initWithHost:(NSString *)host tlsPort:(NSInteger)tlsPort serverPublicKey:(NSData *)serverPublicKey options:(UTMSpiceIOOptions)options {
     if (self = [super init]) {
         self.host = host;
-        self.port = port;
+        self.tlsPort = tlsPort;
+        self.serverPublicKey = serverPublicKey;
         self.options = options;
         self.mutableDisplays = [NSMutableArray array];
         self.mutableSerials = [NSMutableArray array];
@@ -91,7 +93,7 @@ NSString *const kUTMErrorDomain = @"com.utmapp.utm";
             NSURL *relativeSocketFile = [NSURL fileURLWithPath:self.socketUrl.lastPathComponent];
             self.spiceConnection = [[CSConnection alloc] initWithUnixSocketFile:relativeSocketFile];
         } else {
-            self.spiceConnection = [[CSConnection alloc] initWithHost:self.host port:[@(self.port) stringValue]];
+            self.spiceConnection = [[CSConnection alloc] initWithHost:self.host tlsPort:[@(self.tlsPort) stringValue] serverPublicKey:self.serverPublicKey];
         }
         self.spiceConnection.delegate = self;
         self.spiceConnection.audioEnabled = (self.options & UTMSpiceIOOptionsHasAudio) == UTMSpiceIOOptionsHasAudio;

+ 1 - 1
UTM.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved

@@ -15,7 +15,7 @@
       "location" : "https://github.com/utmapp/CocoaSpice.git",
       "state" : {
         "branch" : "visionos",
-        "revision" : "4529c9686259e8d1e94d6253ad2e3a563fd1498d"
+        "revision" : "9591cdf41282a7e6edbe7b705adbb957592ba347"
       }
     },
     {