Swift使用原生Network框架对自签名证书进行双向认证并通信

1.为啥要写这篇文章?

之前作者接触的都是用Objective-C语言及CocoaAsyncSocket进行Socket方面的编程,后来为了节约通信的流量,又加入了Protobuf来进行数据传输。从一开始一条Socket通道来解析所有数据(后台根据消息重要程度划分不同的优先级消息队列,再进行相关推送),并通过通知的形式把业务数据抛给业务层到证券行业的两条Socket通道,一条负责Query(查询),一条负责Push(推送),Query Socket封装成Http格式的请求并可设置请求超时时间,Push Socket数据解析成功之后直接以通知的形式抛给业务层。同时,在2016年直播答题比较火的时候,也用过腾讯的Mars框架开发过直播答题的App。今年也接触了对Socket进行TLS 双向认证来保证数据传输的安全性和可靠性。

最近,作者在进行Swift开发的时候,突然想到Objective-CCocoaAsyncSocket这个框架来进行Socket开发,那么Swift有没有什么好的框架呢?在网上查找Swift Socket编程框架的时候发现了iOS原生开发框架Network,作者抱着试一试的态度对这个框架研究了一下,下面就来给大家分享一下。

2.设置TLS配置并发起连接

这里有几点:1.需要配置成TLS; 2.可选设置禁止那些网络类型;3.使用ipv6协议;4.preferNoProxies要不要禁止开启代理的情况下进行Socket连接;5.最后设置服务端的IP + Port,进行连接。代码如下:

Swift 复制代码
var myQueue = DispatchQueue.global()
let options = NWProtocolTLS.Options()
self?.params = NWParameters(tls: options)

// self.params = NWParameters.tcp
// params.prohibitedInterfaceTypes = [.wifi, .cellular]
// 使用ipv6协议
if let ipOption = self?.params.defaultProtocolStack.internetProtocol as? NWProtocolIP.Options {
    ipOption.version = .v6
}

// 禁止代理
self?.params.preferNoProxies = true
self?.connection = NWConnection(host: NWEndpoint.Host("101.***.***.***"), port: NWEndpoint.Port(integerLiteral: 8902), using: self?.params ?? NWParameters())

self?.connection.start(queue: self!.myQueue)

3.TLS-验证服务端证书

由于我们采用的是TLS双向认证,TLS双向认证分为两步:第一步是认证服务端证书,第二步是认证客户端,只有服务端和客户端都认证完成之后,才认为TLS认证通道建立起来了。

Swift 复制代码
// 设置服务端证书验证的回调
sec_protocol_options_set_verify_block(options.securityProtocolOptions, { [weak self] (sec_protocol_metadata, sec_trust, sec_protocol_verify_complete) in
    // 为信任证书链设置自签名根证书
    let trust = sec_trust_copy_ref(sec_trust).takeRetainedValue()
    if let curBundle = self?.curBundle(), let url = curBundle.url(forResource: "server_cert", withExtension: "cer"),
        let data = try? Data(contentsOf: url), let cert = SecCertificateCreateWithData(nil, data as CFData) {
        if SecTrustSetAnchorCertificates(trust, [cert] as CFArray) != errSecSuccess {
             sec_protocol_verify_complete(false)
             return
        }
     }
     
    // 设置验证策略
    let policy = SecPolicyCreateSSL(true, nil)
    SecTrustSetPolicies(trust, policy)
    SecTrustSetAnchorCertificatesOnly(trust, true)

    // 验证证书链
    var error: CFError?
    if SecTrustEvaluateWithError(trust, &error) {
        sec_protocol_verify_complete(true)
    } else {
        sec_protocol_verify_complete(false)
        print(error!)
    }

第二行配置了TLS认证方式,第五行设置了服务端证书认证的回调。我们这里采用的是证书链验证,其中server_cert.cert是服务端自签名的根证书,内置在客户端。这里的验证原理是,首先从本地把服务端根证书读出来,并把它设置成锚点证书。其次,设置验证策略,SecTrustSetAnchorCertificatesOnly(trust, true)这行表示,只用自己设置的证书链进行验证,不用系统的证书链。最后SecTrustEvaluateWithError(trust, &error)就是对服务端证书进行本地证书链验证并把最后结果回调出去。

由于我们采用的是自签名证书,在进行上述这一步服务端证书校验的过程中,要事先将证书下载到手机上并进行安装,安装成功之后要去系统设置里面把"信任"开关打开,安装过Charles Https抓包证书的小伙伴应该知道这个步骤。对用户来说不太现实,所以这一步我们一般对服务端证书校验诸如PublicKey、Host、Signature(通过导入OpenSSL来进行相关校验)。

4.TLS-设置要验证的客户端证书

读取本地app内p12格式的证书,这里是"client_cert.p12",内置在bundle文件中。password为证书的密码,客户端设置好之后,服务端会对该证书进行认证。

Swift 复制代码
/// 设置客户端证书
/// - Parameter options: NWProtocolTLS.Options
func setClientCert(options: NWProtocolTLS.Options, complete: (() -> Void)?) {
    guard let curBundle = curBundle(), let certUrl = curBundle.url(forResource: "client_cert", withExtension: "p12") else {
        return
    }
    
    guard let certData = try? Data(contentsOf: certUrl) else {
        return
    }

    let cfCertData = certData as CFData
    let password = "******"
    let importOptions = [kSecImportExportPassphrase: password ] as NSDictionary
    var rawItems: CFArray?

    myQueue.async {
        let status = SecPKCS12Import(cfCertData as CFData, // Data from imported Identity.
                                     importOptions as CFDictionary,
                                     &rawItems)
        DispatchQueue.main.async {
            guard status == errSecSuccess, let items = rawItems, let dictionaryItems = items as? Array<Dictionary<String, Any>> else {
                return
            }

            let secIdentity: SecIdentity = dictionaryItems[0][kSecImportItemIdentity as String] as! SecIdentity
            let identity = sec_identity_create(secIdentity)
            sec_protocol_options_set_local_identity(options.securityProtocolOptions, identity!)
                complete?()
            }
        }
    }

证书校验过程中如果出现错误,可在<Security/SecBase.h>查找对应错误码的解释。

5.监听Socket连接状态回调

Swift 复制代码
self?.connection.stateUpdateHandler = { (newState) in
    switch newState {
    case .ready:
        print("state ready")
        // 当状态为ready状态时,就可以发送业务数据了
    case .cancelled:
        print("state cancel")
    case .waiting(let error):
        print("state waiting \(error)")
    case .failed(let error):
        print("state failed \(error)")
    default:
        break
    }
}

当connection处于ready状态下,才能发送业务数据。其它几种状态可以查看苹果的说明文档。

6.通过ProtoBuf协议发送业务层数据

我们先定义一个双方通信的协议,如下

Objective-C 复制代码
/** Socket包包头定义 */
typedef struct {
    /** 包头+包体(2Bytes) */
    UInt16 totalLen;
    /** 包序列号(2Bytes) */
    UInt16 serialNo;
    /** 功能号(2Bytes) */
    UInt16 funcId;
    /** 扩展字段(1Byte) */
    UInt8 extension;
} PackageHeader;

一个完整的数据包由包头 + 包体组成, 包头用2个字节表示包的总长度,2个字节表示序列号(回包时对应到相关请求),2个字节表示功能号(发送的是什么样的消息),1个字节的扩展字段。介绍完包头的定义之后,我们来组装一个业务层登录的数据包。

Swift 复制代码
func sendLoginRequest() {
    let loginRequest = LoginRequest();
    loginRequest.userId = "12345678";
    loginRequest.password = "888888";

    let bodyData = loginRequest.data()
    if let bodyData {
        var header = PackageHeader();
        let headerLen = MemoryLayout<PackageHeader>.size
        header.totalLen = CFSwapInt16HostToBig(UInt16(headerLen + bodyData.count))
        header.serialNo = CFSwapInt16HostToBig(1)
        header.funcId = CFSwapInt16HostToBig(10001)
        header.extension = 0
        
        let headerData = withUnsafeBytes(of: header) { Data($0) }
        let finalData: Data? = headerData + bodyData
        
        self.connection.send(content: finalData, completion: .contentProcessed({ error in
            if let sendError = error {
                print(sendError)
            } else {
                print("消息已发送,内容为: ")
            }
        }))
    }
}

在PackageHeader实例转化成Data之前,要对其中的大于1个字节的整型字段进行字节序转化,关于大端序小端序主机字节序网络字节序相关的介绍大家可自行百度进行了解。下面列了几个大家在组包和拆包过程中要用到几个函数:

函数 说明
MemoryLayout<PackageHeader>.size 取结构体的大小
CFSwapInt16HostToBig 把Int16类型的变量从主机字节序转为网络字节序
CFSwapInt32HostToBig 把Int32类型的变量从主机字节序转为网络字节序
CFSwapInt64HostToBig 把Int64类型的变量从主机字节序转为网络字节序
CFSwapInt16BigToHost 把Int16类型的变量从网络字节序转为主机字节序
CFSwapInt32BigToHost 把Int32类型的变量从网络字节序转为主机字节序
CFSwapInt64BigToHost 把Int32类型的变量从网络字节序转为主机字节序

把包头和包体数据组装成finalData,调用connection的send方法,就可以把数据发送出去,根据回调我们可以知道消息发送成功与否。

7. 解析收到的回包数据为ProtoBuf对应的类

receive这个函数有两个参数,minimumIncompleteLength表示最小接受的字节长度,这里因为我们协议头的长度为7,所以这里设置为7;maximumLength表示接受的最大数据长度,这里简单设置了一下,仅做测试用。网上比较常用的做法的是,第一次把minimumIncompleteLengthmaximumLength都设置成包头的长度,在这里就是7。然后通过解析包头后得到包体的长度比如是30,然后通过receive函数设置minimumIncompleteLengthmaximumLength均为30,就可以读到一个完整的包体数据并解析成具体的类(这里是LoginResponse这个类)。

Swift 复制代码
self?.connection.receive(minimumIncompleteLength: 7, maximumLength: 1024) { content, contentContext, isComplete, error in
    if let error = error {
        print(error)
        self?.connection.cancel()

        return
    }

    if let data = content {
        DispatchQueue.main.async {
            let bodyData = data.subdata(in: 7..<data.count)
            do {
                let loginResponse = try LoginResponse.parse(from: bodyData)
                print("loginresponse: \(String(describing: loginResponse))")
            } catch {
                print("error \(error)")
            }

            print("total len:\(data.count)")
        }
    }

    if isComplete {
        self?.connection.cancel()
        self?.connection = nil
    }
}
相关推荐
大熊猫侯佩3 小时前
由一个 SwiftData “诡异”运行时崩溃而引发的钩深索隐(五)
swiftui·swift·apple watch
大熊猫侯佩1 天前
由一个 SwiftData “诡异”运行时崩溃而引发的钩深索隐(三)
数据库·swiftui·swift
大熊猫侯佩1 天前
由一个 SwiftData “诡异”运行时崩溃而引发的钩深索隐(二)
数据库·swiftui·swift
大熊猫侯佩1 天前
用异步序列优雅的监听 SwiftData 2.0 中历史追踪记录(History Trace)的变化
数据库·swiftui·swift
大熊猫侯佩1 天前
由一个 SwiftData “诡异”运行时崩溃而引发的钩深索隐(一)
数据库·swiftui·swift
season_zhu2 天前
iOS开发:关于日志框架
ios·架构·swift
大熊猫侯佩2 天前
SwiftUI 中如何花样玩转 SF Symbols 符号动画和过渡特效
swiftui·swift·apple
大熊猫侯佩2 天前
SwiftData 共享数据库在 App 中的改变无法被 Widgets 感知的原因和解决
swiftui·swift·apple
大熊猫侯佩2 天前
使用令牌(Token)进一步优化 SwiftData 2.0 中历史记录追踪(History Trace)的使用
数据库·swift·apple
大熊猫侯佩2 天前
SwiftUI 在 iOS 18 中的 ForEach 点击手势逻辑发生改变的解决
swiftui·swift·apple