// // NearbyConnectionManager.swift // NearDrop // // Created by Grishka on 08.04.2023. // import Foundation import Network import System import CryptoKit import SwiftECC public struct RemoteDeviceInfo{ public let name:String public let type:DeviceType public let qrCodeData:Data? public var id:String? init(name: String, type: DeviceType, id: String? = nil) { self.name = name self.type = type self.id = id self.qrCodeData = nil } init(info:EndpointInfo, id: String? = nil){ self.name=info.name! self.type=info.deviceType self.qrCodeData=info.qrCodeData self.id=id } public enum DeviceType:Int32{ case unknown=0 case phone case tablet case computer public static func fromRawValue(value:Int) -> DeviceType{ switch value { case 0: return .unknown case 1: return .phone case 2: return .tablet case 3: return .computer default: return .unknown } } } } public enum NearbyError:Error{ case protocolError(_ message:String) case requiredFieldMissing(_ message:String) case ukey2 case inputOutput case canceled(reason:CancellationReason) public enum CancellationReason{ case userRejected, userCanceled, notEnoughSpace, unsupportedType, timedOut } } public struct TransferMetadata{ public let files:[FileMetadata] public let id:String public let pinCode:String? public let textDescription:String? init(files: [FileMetadata], id: String, pinCode: String?, textDescription: String?=nil){ self.files = files self.id = id self.pinCode = pinCode self.textDescription = textDescription } } public struct FileMetadata{ public let name:String public let size:Int64 public let mimeType:String } struct FoundServiceInfo{ let service:NWBrowser.Result var device:RemoteDeviceInfo? } struct OutgoingTransferInfo{ let service:NWBrowser.Result let device:RemoteDeviceInfo let connection:OutboundNearbyConnection let delegate:ShareExtensionDelegate } struct EndpointInfo{ var name:String? let deviceType:RemoteDeviceInfo.DeviceType let qrCodeData:Data? init(name: String, deviceType: RemoteDeviceInfo.DeviceType){ self.name = name self.deviceType = deviceType self.qrCodeData=nil } init?(data:Data){ guard data.count>17 else {return nil} let hasName=(data[0] & 0x10)==0 let deviceNameLength:Int let deviceName:String? if hasName{ deviceNameLength=Int(data[17]) guard data.count>=deviceNameLength+18 else {return nil} guard let _deviceName=String(data: data[18..<(18+deviceNameLength)], encoding: .utf8) else {return nil} deviceName=_deviceName }else{ deviceNameLength=0 deviceName=nil } let rawDeviceType:Int=Int(data[0] & 7) >> 1 self.name=deviceName self.deviceType=RemoteDeviceInfo.DeviceType.fromRawValue(value: rawDeviceType) var offset=1+16 if hasName{ offset=offset+1+deviceNameLength } var qrCodeData:Data?=nil while data.count-offset>2{ // read TLV records, if any let type=data[offset] let length=Int(data[offset+1]) offset=offset+2 if data.count-offset>=length{ if type==1{ // QR code data qrCodeData=data.subdata(in: offset..Data{ // 1 byte: Version(3 bits)|Visibility(1 bit)|Device Type(3 bits)|Reserved(1 bits) // Device types: unknown=0, phone=1, tablet=2, laptop=3 var endpointInfo:[UInt8]=[UInt8(deviceType.rawValue << 1)] // 16 bytes: unknown random bytes for _ in 0...15{ endpointInfo.append(UInt8.random(in: 0...255)) } // Device name in UTF-8 prefixed with 1-byte length var nameChars=[UInt8](name!.utf8) if nameChars.count>255{ nameChars=[UInt8](nameChars[0..<255]) } endpointInfo.append(UInt8(nameChars.count)) for ch in nameChars{ endpointInfo.append(UInt8(ch)) } return Data(endpointInfo) } } public protocol MainAppDelegate{ func obtainUserConsent(for transfer:TransferMetadata, from device:RemoteDeviceInfo) func incomingTransfer(id:String, didFinishWith error:Error?) } public protocol ShareExtensionDelegate:AnyObject{ func addDevice(device:RemoteDeviceInfo) func removeDevice(id:String) func startTransferWithQrCode(device:RemoteDeviceInfo) func connectionWasEstablished(pinCode:String) func connectionFailed(with error:Error) func transferAccepted() func transferProgress(progress:Double) func transferFinished() } public class NearbyConnectionManager : NSObject, NetServiceDelegate, InboundNearbyConnectionDelegate, OutboundNearbyConnectionDelegate{ private var tcpListener:NWListener; public let endpointID:[UInt8]=generateEndpointID() private var mdnsService:NetService? private var activeConnections:[String:InboundNearbyConnection]=[:] private var foundServices:[String:FoundServiceInfo]=[:] private var shareExtensionDelegates:[ShareExtensionDelegate]=[] private var outgoingTransfers:[String:OutgoingTransferInfo]=[:] public var mainAppDelegate:(any MainAppDelegate)? private var discoveryRefCount=0 private var browser:NWBrowser? private var qrCodePublicKey:ECPublicKey? private var qrCodePrivateKey:ECPrivateKey? private var qrCodeAdvertisingToken:Data? private var qrCodeNameEncryptionKey:SymmetricKey? private var qrCodeData:Data? public static let shared=NearbyConnectionManager() override init() { tcpListener=try! NWListener(using: NWParameters(tls: .none)) super.init() } public func becomeVisible(){ startTCPListener() } private func startTCPListener(){ tcpListener.stateUpdateHandler={(state:NWListener.State) in if case .ready = state { self.initMDNS() } } tcpListener.newConnectionHandler={(connection:NWConnection) in let id=UUID().uuidString let conn=InboundNearbyConnection(connection: connection, id: id) self.activeConnections[id]=conn conn.delegate=self conn.start() } tcpListener.start(queue: .global(qos: .utility)) } private static func generateEndpointID()->[UInt8]{ var id:[UInt8]=[] let alphabet="0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ".compactMap {UInt8($0.asciiValue!)} for _ in 0...3{ id.append(alphabet[Int.random(in: 0..=0) if discoveryRefCount==0{ browser?.cancel() browser=nil } } public func addShareExtensionDelegate(_ delegate:ShareExtensionDelegate){ shareExtensionDelegates.append(delegate) for service in foundServices.values{ guard let device=service.device else {continue} delegate.addDevice(device: device) } } public func removeShareExtensionDelegate(_ delegate:ShareExtensionDelegate){ shareExtensionDelegates.removeAll(where: {$0===delegate}) } public func cancelOutgoingTransfer(id:String){ guard let transfer=outgoingTransfers[id] else {return} transfer.connection.cancel() } private func endpointID(for service:NWBrowser.Result)->String?{ guard case let NWEndpoint.service(name: serviceName, type: _, domain: _, interface: _)=service.endpoint else {return nil} guard let nameData=Data.dataFromUrlSafeBase64(serviceName) else {return nil} guard nameData.count>=10 else {return nil} let pcp=nameData[0] guard pcp==0x23 else {return nil} let endpointID=String(data: nameData.subdata(in: 1..<5), encoding: .ascii)! let serviceIDHash=nameData.subdata(in: 5..<8) guard serviceIDHash==Data([0xFC, 0x9F, 0x5E]) else {return nil} return endpointID } private func maybeAddFoundDevice(service:NWBrowser.Result){ #if DEBUG print("found service \(service)") #endif for interface in service.interfaces{ if case .loopback=interface.type{ #if DEBUG print("ignoring localhost service") #endif return } } guard let endpointID=endpointID(for: service) else {return} #if DEBUG print("service name is valid, endpoint ID \(endpointID)") #endif var foundService=FoundServiceInfo(service: service) guard case let NWBrowser.Result.Metadata.bonjour(txtRecord)=service.metadata else {return} guard let endpointInfoEncoded=txtRecord.dictionary["n"] else {return} guard let endpointInfoSerialized=Data.dataFromUrlSafeBase64(endpointInfoEncoded) else {return} guard var endpointInfo=EndpointInfo(data: endpointInfoSerialized) else {return} var deviceInfo:RemoteDeviceInfo? if let _=endpointInfo.name{ deviceInfo=addFoundDevice(foundService: &foundService, endpointInfo: endpointInfo, endpointID: endpointID) } if let qrData=endpointInfo.qrCodeData, let _=qrCodeAdvertisingToken{ #if DEBUG print("Device has QR data: \(qrData.base64EncodedString()), our advertising token is \(qrCodeAdvertisingToken!.base64EncodedString())") #endif if qrData==qrCodeAdvertisingToken!{ if let deviceInfo=deviceInfo{ for delegate in shareExtensionDelegates{ delegate.startTransferWithQrCode(device: deviceInfo) } } }else if qrData.count>28{ do{ let box=try AES.GCM.SealedBox(combined: qrData) let decryptedName=try AES.GCM.open(box, using: qrCodeNameEncryptionKey!, authenticating: qrCodeAdvertisingToken!) guard let name=String.init(data: decryptedName, encoding: .utf8) else {return} endpointInfo.name=name let deviceInfo=addFoundDevice(foundService: &foundService, endpointInfo: endpointInfo, endpointID: endpointID) for delegate in shareExtensionDelegates{ delegate.startTransferWithQrCode(device: deviceInfo) } }catch{ #if DEBUG print("Error decrypting QR code data of an invisible device: \(error)") #endif } } } } private func addFoundDevice(foundService:inout FoundServiceInfo, endpointInfo:EndpointInfo, endpointID:String) -> RemoteDeviceInfo{ let deviceInfo=RemoteDeviceInfo(info: endpointInfo, id: endpointID) foundService.device=deviceInfo foundServices[endpointID]=foundService for delegate in shareExtensionDelegates{ delegate.addDevice(device: deviceInfo) } return deviceInfo } private func maybeRemoveFoundDevice(service:NWBrowser.Result){ guard let endpointID=endpointID(for: service) else {return} guard let _=foundServices.removeValue(forKey: endpointID) else {return} for delegate in shareExtensionDelegates { delegate.removeDevice(id: endpointID) } } public func generateQrCodeKey() -> String{ let domain=Domain.instance(curve: .EC256r1) let (pubKey, privKey)=domain.makeKeyPair() qrCodePublicKey=pubKey qrCodePrivateKey=privKey var keyData=Data() keyData.append(contentsOf: [0, 0, 2]) let keyBytes=Data(pubKey.w.x.asSignedBytes()) // Sometimes, for some keys, there will be a leading zero byte. Strip that, Android really hates it (it breaks the endpoint info) keyData.append(keyBytes.suffixOfAtMost(numBytes: 32)) let ikm=SymmetricKey(data: keyData) qrCodeAdvertisingToken=NearbyConnection.hkdf(inputKeyMaterial: ikm, salt: Data(), info: "advertisingContext".data(using: .utf8)!, outputByteCount: 16).data() qrCodeNameEncryptionKey=NearbyConnection.hkdf(inputKeyMaterial: ikm, salt: Data(), info: "encryptionKey".data(using: .utf8)!, outputByteCount: 16) qrCodeData=keyData return keyData.urlSafeBase64EncodedString() } public func clearQrCodeKey(){ qrCodePublicKey=nil qrCodePrivateKey=nil qrCodeAdvertisingToken=nil qrCodeNameEncryptionKey=nil qrCodeData=nil } public func startOutgoingTransfer(deviceID:String, delegate:ShareExtensionDelegate, urls:[URL]){ guard let info=foundServices[deviceID] else {return} let tcp=NWProtocolTCP.Options.init() tcp.noDelay=true let nwconn=NWConnection(to: info.service.endpoint, using: NWParameters(tls: .none, tcp: tcp)) let conn=OutboundNearbyConnection(connection: nwconn, id: deviceID, urlsToSend: urls) conn.delegate=self conn.qrCodePrivateKey=qrCodePrivateKey let transfer=OutgoingTransferInfo(service: info.service, device: info.device!, connection: conn, delegate: delegate) outgoingTransfers[deviceID]=transfer conn.start() } func outboundConnectionWasEstablished(connection: OutboundNearbyConnection) { guard let transfer=outgoingTransfers[connection.id] else {return} DispatchQueue.main.async { transfer.delegate.connectionWasEstablished(pinCode: connection.pinCode!) } } func outboundConnectionTransferAccepted(connection: OutboundNearbyConnection) { guard let transfer=outgoingTransfers[connection.id] else {return} DispatchQueue.main.async { transfer.delegate.transferAccepted() } } func outboundConnection(connection: OutboundNearbyConnection, transferProgress: Double) { guard let transfer=outgoingTransfers[connection.id] else {return} DispatchQueue.main.async { transfer.delegate.transferProgress(progress: transferProgress) } } func outboundConnection(connection: OutboundNearbyConnection, failedWithError: Error) { guard let transfer=outgoingTransfers[connection.id] else {return} DispatchQueue.main.async { transfer.delegate.connectionFailed(with: failedWithError) } outgoingTransfers.removeValue(forKey: connection.id) } func outboundConnectionTransferFinished(connection: OutboundNearbyConnection) { guard let transfer=outgoingTransfers[connection.id] else {return} DispatchQueue.main.async { transfer.delegate.transferFinished() } outgoingTransfers.removeValue(forKey: connection.id) } }