Add Client Certificate

This commit is contained in:
Djorkaeff Alexandre 2024-01-18 21:50:59 -03:00
parent 8f3bb1a99b
commit 0672f56f10
10 changed files with 309 additions and 128 deletions

View File

@ -11,9 +11,18 @@ protocol RocketChatClientProtocol {
func sendRead(rid: String) -> AnyPublisher<ReadResponse, RocketChatError>
}
final class RocketChatClient {
final class RocketChatClient: NSObject {
private let server: Server
private lazy var session = URLSession(
configuration: .default,
delegate: URLSesionClientCertificateHandling(
certificate: server.certificate,
password: server.password
),
delegateQueue: nil
)
init(server: Server) {
self.server = server
}
@ -32,7 +41,7 @@ final class RocketChatClient {
urlRequest.httpMethod = request.method.rawValue
urlRequest.httpBody = request.body
return URLSession.shared.dataTaskPublisher(for: urlRequest)
return session.dataTaskPublisher(for: urlRequest)
.tryMap { (data, response) in
guard let httpResponse = response as? HTTPURLResponse, 200...299 ~= httpResponse.statusCode else {
throw RocketChatError.unauthorized

View File

@ -0,0 +1,95 @@
// https://medium.com/@hamidptb/implementing-mtls-on-ios-using-urlsession-and-cloudflare-890b76aca66c
import Foundation
final class URLSesionClientCertificateHandling: NSObject, URLSessionDelegate {
private let certificate: Data?
private let password: String?
init(certificate: Data?, password: String?) {
self.certificate = certificate
self.password = password
}
public func urlSession(
_: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodClientCertificate else {
completionHandler(.performDefaultHandling, nil)
return
}
guard let credential = Credentials.urlCredential(certificate: certificate, password: password) else {
completionHandler(.performDefaultHandling, nil)
return
}
challenge.sender?.use(credential, for: challenge)
completionHandler(.useCredential, credential)
}
}
fileprivate typealias UserCertificate = (data: Data, password: String)
fileprivate final class Credentials {
static func urlCredential(certificate: Data?, password: String?) -> URLCredential? {
guard let certificate, let password else { return nil }
let p12Contents = PKCS12(pkcs12Data: certificate, password: password)
guard let identity = p12Contents.identity else {
return nil
}
return URLCredential(identity: identity, certificates: nil, persistence: .none)
}
}
fileprivate struct PKCS12 {
let label: String?
let keyID: NSData?
let trust: SecTrust?
let certChain: [SecTrust]?
let identity: SecIdentity?
public init(pkcs12Data: Data, password: String) {
let importPasswordOption: NSDictionary
= [kSecImportExportPassphrase as NSString: password]
var items: CFArray?
let secError: OSStatus
= SecPKCS12Import(pkcs12Data as NSData,
importPasswordOption, &items)
guard secError == errSecSuccess else {
if secError == errSecAuthFailed {
NSLog("Incorrect password?")
}
fatalError("Error trying to import PKCS12 data")
}
guard let theItemsCFArray = items else { fatalError() }
let theItemsNSArray: NSArray = theItemsCFArray as NSArray
guard let dictArray
= theItemsNSArray as? [[String: AnyObject]]
else {
fatalError()
}
label = dictArray.element(for: kSecImportItemLabel)
keyID = dictArray.element(for: kSecImportItemKeyID)
trust = dictArray.element(for: kSecImportItemTrust)
certChain = dictArray.element(for: kSecImportItemCertChain)
identity = dictArray.element(for: kSecImportItemIdentity)
}
}
fileprivate extension Array where Element == [String: AnyObject] {
func element<T>(for key: CFString) -> T? {
for dictElement in self {
if let value = dictElement[key as String] as? T {
return value
}
}
return nil
}
}

View File

@ -74,6 +74,8 @@ final class DefaultDatabase: ServersDatabase {
server.iconURL = updatedServer.iconURL
server.useRealName = updatedServer.useRealName
server.loggedUser = user(from: updatedServer.loggedUser)
server.certificate = updatedServer.clientSSL?.certificate
server.password = updatedServer.clientSSL?.password
} else {
Server(
context: viewContext,
@ -81,7 +83,9 @@ final class DefaultDatabase: ServersDatabase {
name: updatedServer.name,
url: updatedServer.url,
useRealName: updatedServer.useRealName,
loggedUser: user(from: updatedServer.loggedUser)
loggedUser: user(from: updatedServer.loggedUser),
certificate: updatedServer.clientSSL?.certificate,
password: updatedServer.clientSSL?.password
)
}

View File

@ -8,8 +8,10 @@
<relationship name="server" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Server" inverseName="loggedUser" inverseEntity="Server"/>
</entity>
<entity name="Server" representedClassName=".Server" syncable="YES">
<attribute name="certificate" optional="YES" attributeType="Binary"/>
<attribute name="iconURL" optional="YES" attributeType="URI"/>
<attribute name="name" optional="YES" attributeType="String"/>
<attribute name="password" optional="YES" attributeType="String"/>
<attribute name="updatedSince" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="url" optional="YES" attributeType="URI"/>
<attribute name="useRealName" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>

View File

@ -13,6 +13,8 @@ public final class Server: NSManagedObject {
@NSManaged public var url: URL
@NSManaged public var useRealName: Bool
@NSManaged public var loggedUser: LoggedUser
@NSManaged public var certificate: Data?
@NSManaged public var password: String?
@available(*, unavailable)
init() {
@ -36,7 +38,9 @@ public final class Server: NSManagedObject {
updatedSince: Date? = nil,
url: URL,
useRealName: Bool,
loggedUser: LoggedUser
loggedUser: LoggedUser,
certificate: Data? = nil,
password: String? = nil
) {
let entity = NSEntityDescription.entity(forEntityName: "Server", in: context)!
super.init(entity: entity, insertInto: context)
@ -46,6 +50,8 @@ public final class Server: NSManagedObject {
self.url = url
self.useRealName = useRealName
self.loggedUser = loggedUser
self.certificate = certificate
self.password = password
}
}

View File

@ -112,6 +112,9 @@
1E9A71692B59B6E100477BA2 /* MessageSender.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E9A71682B59B6E100477BA2 /* MessageSender.swift */; };
1E9A716F2B59CBCA00477BA2 /* AttachmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E9A716E2B59CBCA00477BA2 /* AttachmentView.swift */; };
1E9A71712B59CC1300477BA2 /* Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E9A71702B59CC1300477BA2 /* Attachment.swift */; };
1E9A71742B59F36E00477BA2 /* ClientSSL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E9A71722B59F34E00477BA2 /* ClientSSL.swift */; };
1E9A71752B59F36E00477BA2 /* ClientSSL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E9A71722B59F34E00477BA2 /* ClientSSL.swift */; };
1E9A71772B59FCA900477BA2 /* URLSessionCertificateHandling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E9A71762B59FCA900477BA2 /* URLSessionCertificateHandling.swift */; };
1EB375892B55DBFB00AEC3D7 /* Server.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EB375882B55DBFB00AEC3D7 /* Server.swift */; };
1EB8EF722510F1EE00F352B7 /* Storage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EB8EF712510F1EE00F352B7 /* Storage.swift */; };
1EC6ACB722CB9FC300A41C61 /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 1EC6ACB522CB9FC300A41C61 /* MainInterface.storyboard */; };
@ -398,6 +401,8 @@
1E9A71682B59B6E100477BA2 /* MessageSender.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageSender.swift; sourceTree = "<group>"; };
1E9A716E2B59CBCA00477BA2 /* AttachmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentView.swift; sourceTree = "<group>"; };
1E9A71702B59CC1300477BA2 /* Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attachment.swift; sourceTree = "<group>"; };
1E9A71722B59F34E00477BA2 /* ClientSSL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientSSL.swift; sourceTree = "<group>"; };
1E9A71762B59FCA900477BA2 /* URLSessionCertificateHandling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionCertificateHandling.swift; sourceTree = "<group>"; };
1EB375882B55DBFB00AEC3D7 /* Server.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Server.swift; sourceTree = "<group>"; };
1EB8EF712510F1EE00F352B7 /* Storage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storage.swift; sourceTree = "<group>"; };
1EC6ACB022CB9FC300A41C61 /* ShareRocketChatRN.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareRocketChatRN.appex; sourceTree = BUILT_PRODUCTS_DIR; };
@ -593,6 +598,7 @@
1E29A2E92B585B070093C03C /* FailableDecodable.swift */,
1E29A2EA2B585B070093C03C /* HTTP */,
1E29A2EE2B585B070093C03C /* RocketChatError.swift */,
1E9A71762B59FCA900477BA2 /* URLSessionCertificateHandling.swift */,
);
path = Client;
sourceTree = "<group>";
@ -699,6 +705,7 @@
1E470E822513A71E00E3DD1D /* RocketChat.swift */,
1EB8EF712510F1EE00F352B7 /* Storage.swift */,
1ED038A82B5090AD00C007D4 /* MMKV.swift */,
1E9A71722B59F34E00477BA2 /* ClientSSL.swift */,
);
path = RocketChat;
sourceTree = "<group>";
@ -1797,6 +1804,7 @@
1E76CBD225152C730067298C /* Data+Extensions.swift in Sources */,
1E76CBD125152C710067298C /* Date+Extensions.swift in Sources */,
1E76CBD425152C790067298C /* Database.swift in Sources */,
1E9A71742B59F36E00477BA2 /* ClientSSL.swift in Sources */,
1ED038AD2B50927B00C007D4 /* WatermelonDB+Extensions.swift in Sources */,
1ED038A52B50900800C007D4 /* Bundle+Extensions.swift in Sources */,
1E76CBC325152A460067298C /* String+Extensions.swift in Sources */,
@ -1870,6 +1878,7 @@
1E29A2FF2B585B070093C03C /* TokenAdapter.swift in Sources */,
1E29A3052B585B070093C03C /* Request.swift in Sources */,
1E6436242B59998A009F0CE1 /* ExtensionDelegate.swift in Sources */,
1E9A71772B59FCA900477BA2 /* URLSessionCertificateHandling.swift in Sources */,
1E29A2EF2B585B070093C03C /* RocketChatClient.swift in Sources */,
1E29A2FB2B585B070093C03C /* MessagesRequest.swift in Sources */,
1E29A31D2B5871B60093C03C /* Date+Extensions.swift in Sources */,
@ -1955,6 +1964,7 @@
7AAB3E23257E6A6E00707CF6 /* Data+Extensions.swift in Sources */,
7AAB3E24257E6A6E00707CF6 /* Date+Extensions.swift in Sources */,
7AAB3E25257E6A6E00707CF6 /* Database.swift in Sources */,
1E9A71752B59F36E00477BA2 /* ClientSSL.swift in Sources */,
1ED038AF2B50927B00C007D4 /* WatermelonDB+Extensions.swift in Sources */,
1ED038A72B50900800C007D4 /* Bundle+Extensions.swift in Sources */,
7AAB3E26257E6A6E00707CF6 /* String+Extensions.swift in Sources */,

View File

@ -0,0 +1,28 @@
import Foundation
struct ClientSSL: Codable {
let path: String
let password: String
}
extension MMKV {
func clientSSL(for url: URL) -> ClientSSL? {
guard let host = url.host else {
return nil
}
guard let rawClientSSL = string(forKey: host) else {
return nil
}
guard let data = rawClientSSL.data(using: .utf8) else {
return nil
}
guard let clientSSL = try? JSONDecoder().decode(ClientSSL.self, from: data) else {
return nil
}
return clientSSL
}
}

View File

@ -1,40 +1,40 @@
import Foundation
extension MMKV {
static func build() -> MMKV {
let password = SecureStorage().getSecureKey("com.MMKV.default".toHex())
let groupDir = FileManager.default.groupDir()
MMKV.initialize(rootDir: nil, groupDir: groupDir, logLevel: MMKVLogLevel.none)
guard let mmkv = MMKV(mmapID: "default", cryptKey: password?.data(using: .utf8), mode: MMKVMode.multiProcess) else {
fatalError("Could not initialize MMKV instance.")
}
return mmkv
}
func userToken(for userId: String) -> String {
guard let userToken = string(forKey: "reactnativemeteor_usertoken-\(userId)") else {
fatalError("userToken is nil for userId \(userId)")
}
return userToken
}
func userId(for server: String) -> String {
guard let userId = string(forKey: "reactnativemeteor_usertoken-\(server)") else {
fatalError("userId is nil for server \(server)")
}
return userId
}
func privateKey(for server: String) -> String {
guard let privateKey = string(forKey: "\(server)-RC_E2E_PRIVATE_KEY") else {
fatalError("privateKey is nil for server \(server)")
}
return privateKey
}
static func build() -> MMKV {
let password = SecureStorage().getSecureKey("com.MMKV.default".toHex())
let groupDir = FileManager.default.groupDir()
MMKV.initialize(rootDir: nil, groupDir: groupDir, logLevel: MMKVLogLevel.none)
guard let mmkv = MMKV(mmapID: "default", cryptKey: password?.data(using: .utf8), mode: MMKVMode.multiProcess) else {
fatalError("Could not initialize MMKV instance.")
}
return mmkv
}
func userToken(for userId: String) -> String {
guard let userToken = string(forKey: "reactnativemeteor_usertoken-\(userId)") else {
fatalError("userToken is nil for userId \(userId)")
}
return userToken
}
func userId(for server: String) -> String {
guard let userId = string(forKey: "reactnativemeteor_usertoken-\(server)") else {
fatalError("userId is nil for server \(server)")
}
return userId
}
func privateKey(for server: String) -> String {
guard let privateKey = string(forKey: "\(server)-RC_E2E_PRIVATE_KEY") else {
fatalError("privateKey is nil for server \(server)")
}
return privateKey
}
}

View File

@ -4,79 +4,100 @@ import WatchConnectivity
@objc
final class WatchConnection: NSObject {
private let database = WatermelonDB.Database(name: "default")
private let mmkv = MMKV.build()
private let session: WCSession
@objc init(session: WCSession) {
self.session = session
super.init()
if WCSession.isSupported() {
session.delegate = self
session.activate()
}
}
private func getMessage() -> WatchMessage {
let serversQuery = database.query(raw: "select * from servers") as [DBServer]
let servers = serversQuery.compactMap { item -> WatchMessage.Server? in
let userId = mmkv.userId(for: item.identifier)
let userToken = mmkv.userToken(for: userId)
let usersQuery = database.query(raw: "select * from users where token == ? limit 1", [userToken]) as [DBUser]
guard let user = usersQuery.first else {
return nil
}
return WatchMessage.Server(
url: item.url,
name: item.name,
iconURL: item.iconURL,
useRealName: item.useRealName == 1 ? true : false,
loggedUser: .init(
id: userId,
token: userToken,
name: user.name,
username: user.username
)
)
}
return WatchMessage(servers: servers)
}
private func encodedMessage() -> [String: Any] {
do {
let data = try JSONEncoder().encode(getMessage())
guard let dictionary = try JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [String: Any] else {
fatalError("Could not serialize message: \(getMessage())")
}
return dictionary
} catch {
fatalError("Could not encode message: \(getMessage())")
}
}
private let database = WatermelonDB.Database(name: "default")
private let mmkv = MMKV.build()
private let session: WCSession
@objc init(session: WCSession) {
self.session = session
super.init()
if WCSession.isSupported() {
session.delegate = self
session.activate()
}
}
private func getClientSSL(from clientSSL: ClientSSL?) -> WatchMessage.Server.ClientSSL? {
guard let clientSSL else {
return nil
}
guard FileManager.default.fileExists(atPath: clientSSL.path) else {
return nil
}
guard let certificate = NSData(contentsOfFile: clientSSL.path) else {
return nil
}
return .init(
certificate: Data(referencing: certificate),
password: clientSSL.password
)
}
private func getMessage() -> WatchMessage {
let serversQuery = database.query(raw: "select * from servers") as [DBServer]
let servers = serversQuery.compactMap { item -> WatchMessage.Server? in
let userId = mmkv.userId(for: item.identifier)
let userToken = mmkv.userToken(for: userId)
let clientSSL = mmkv.clientSSL(for: item.url)
let usersQuery = database.query(raw: "select * from users where token == ? limit 1", [userToken]) as [DBUser]
guard let user = usersQuery.first else {
return nil
}
return WatchMessage.Server(
url: item.url,
name: item.name,
iconURL: item.iconURL,
useRealName: item.useRealName == 1 ? true : false,
loggedUser: .init(
id: userId,
token: userToken,
name: user.name,
username: user.username
),
clientSSL: getClientSSL(from: clientSSL)
)
}
return WatchMessage(servers: servers)
}
private func encodedMessage() -> [String: Any] {
do {
let data = try JSONEncoder().encode(getMessage())
guard let dictionary = try JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [String: Any] else {
fatalError("Could not serialize message: \(getMessage())")
}
return dictionary
} catch {
fatalError("Could not encode message: \(getMessage())")
}
}
}
extension WatchConnection: WCSessionDelegate {
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
}
func sessionDidBecomeInactive(_ session: WCSession) {
session.activate()
}
func sessionDidDeactivate(_ session: WCSession) {
session.activate()
}
func session(_ session: WCSession, didReceiveMessage message: [String : Any], replyHandler: @escaping ([String : Any]) -> Void) {
replyHandler(encodedMessage())
}
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
}
func sessionDidBecomeInactive(_ session: WCSession) {
session.activate()
}
func sessionDidDeactivate(_ session: WCSession) {
session.activate()
}
func session(_ session: WCSession, didReceiveMessage message: [String : Any], replyHandler: @escaping ([String : Any]) -> Void) {
replyHandler(encodedMessage())
}
}

View File

@ -1,20 +1,26 @@
import Foundation
struct WatchMessage: Codable {
let servers: [Server]
struct Server: Codable {
let url: URL
let name: String
let iconURL: URL
let useRealName: Bool
let loggedUser: LoggedUser
struct LoggedUser: Codable {
let id: String
let token: String
let name: String
let username: String
}
}
let servers: [Server]
struct Server: Codable {
let url: URL
let name: String
let iconURL: URL
let useRealName: Bool
let loggedUser: LoggedUser
let clientSSL: ClientSSL?
struct LoggedUser: Codable {
let id: String
let token: String
let name: String
let username: String
}
struct ClientSSL: Codable {
let certificate: Data
let password: String
}
}
}