Skip to content

Commit

Permalink
feat: Certificate pinning and proxy mode in WireAPI - WPB-10450 (#2237
Browse files Browse the repository at this point in the history
)

Co-authored-by: François Benaiteau <[email protected]>
  • Loading branch information
samwyndham and netbe authored Dec 12, 2024
1 parent 5d83439 commit 9f69bb9
Show file tree
Hide file tree
Showing 32 changed files with 899 additions and 510 deletions.
3 changes: 2 additions & 1 deletion WireAPI/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ let package = Package(
.process("APIs/SelfUserAPI/Resources"),
.process("APIs/UserClientsAPI/Resources"),
.process("Network/PushChannel/Resources"),
.process("Authentication/Resources")
.process("Authentication/Resources"),
.process("Backend/Resources")
]
)
]
Expand Down
23 changes: 14 additions & 9 deletions WireAPI/Sources/WireAPI/Assembly.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,37 +23,37 @@ public final class Assembly {

let userID: UUID
let clientID: String
let backendURL: URL
let backendWebSocketURL: URL
let backendEnvironment: BackendEnvironment
let minTLSVersion: TLSVersion
let cookieEncryptionKey: Data

public init(
userID: UUID,
clientID: String,
backendURL: URL,
backendWebSocketURL: URL,
backendEnvironment: BackendEnvironment,
minTLSVersion: TLSVersion,
cookieEncryptionKey: Data
) {
self.userID = userID
self.clientID = clientID
self.backendURL = backendURL
self.backendWebSocketURL = backendWebSocketURL
self.backendEnvironment = backendEnvironment
self.minTLSVersion = minTLSVersion
self.cookieEncryptionKey = cookieEncryptionKey
}

private lazy var keychain: some KeychainProtocol = Keychain()
private lazy var urlSessionConfigurationFactory = URLSessionConfigurationFactory(minTLSVersion: minTLSVersion)
private lazy var urlSessionConfigurationFactory = URLSessionConfigurationFactory(
minTLSVersion: minTLSVersion,
proxySettings: backendEnvironment.proxySettings
)

private lazy var apiService: some APIServiceProtocol = APIService(
networkService: apiNetworkService,
authenticationManager: authenticationManager
)

public lazy var apiNetworkService: NetworkService = {
let service = NetworkService(baseURL: backendURL)
let service = NetworkService(baseURL: backendEnvironment.url, serverTrustValidator: serverTrustValidator)
let config = urlSessionConfigurationFactory.makeRESTAPISessionConfiguration()
let session = URLSession(configuration: config, delegate: service, delegateQueue: nil)
service.configure(with: session)
Expand All @@ -66,7 +66,10 @@ public final class Assembly {
)

private lazy var pushChannelNetworkService: NetworkService = {
let service = NetworkService(baseURL: backendURL)
let service = NetworkService(
baseURL: backendEnvironment.webSocketURL,
serverTrustValidator: serverTrustValidator
)
let config = urlSessionConfigurationFactory.makeWebSocketSessionConfiguration()
let session = URLSession(configuration: config, delegate: service, delegateQueue: nil)
service.configure(with: session)
Expand All @@ -85,4 +88,6 @@ public final class Assembly {
keychain: keychain
)

private lazy var serverTrustValidator = ServerTrustValidator(pinnedKeys: backendEnvironment.pinnedKeys)

}
55 changes: 55 additions & 0 deletions WireAPI/Sources/WireAPI/Models/Backend/BackendEnvironment.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
//
// Wire
// Copyright (C) 2024 Wire Swiss GmbH
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see http://www.gnu.org/licenses/.
//

import Foundation

/// A collection of data for connecting to a given backend environment (e.g. Production, Staging, etc).

public struct BackendEnvironment {

/// The `URL` of the backend.

let url: URL

/// The `URL` of the WebSocket endpoint.

let webSocketURL: URL

/// The pinned keys for the backend for use with certificate pinning.

let pinnedKeys: [PinnedKey]

/// The proxy settings for the backend if any.

let proxySettings: ProxySettings?

/// Creates a new `BackendEnvironment`.
///
/// - Parameter url: The `URL` of the backend.
/// - Parameter webSocketURL: The `URL` of the WebSocket endpoint.
/// - Parameter pinnedKeys: The pinned keys for the backend for use with certificate pinning.
/// - Parameter proxySettings: The proxy settings for the backend if any.

public init(url: URL, webSocketURL: URL, pinnedKeys: [PinnedKey], proxySettings: ProxySettings?) {
self.url = url
self.webSocketURL = webSocketURL
self.pinnedKeys = pinnedKeys
self.proxySettings = proxySettings
}

}
74 changes: 74 additions & 0 deletions WireAPI/Sources/WireAPI/Models/Backend/PinnedKey.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
//
// Wire
// Copyright (C) 2024 Wire Swiss GmbH
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see http://www.gnu.org/licenses/.
//

import Foundation
@preconcurrency import Security

/// Associates a list of `hosts` with a public `key`.

public struct PinnedKey: Sendable {

public enum Failure: Error {
case invalidKeyData
}

public enum Host: Sendable {
case endsWith(String)
case equals(String)
}

let key: SecKey
let hosts: [Host]

public init(key: SecKey, hosts: [Host]) {
self.key = key
self.hosts = hosts
}

public init(key: Data, hosts: [Host]) throws(Failure) {
self.key = try Self.key(for: key)
self.hosts = hosts
}

/// Returns `true` if `host` matches any of the `hosts` in `self`.

func matches(host: String) -> Bool {
hosts.contains {
switch $0 {
case let .endsWith(suffix):
host.hasSuffix(suffix)
case let .equals(value):
host == value
}
}
}

// MARK: - Private

private static func key(for data: Data) throws(Failure) -> SecKey {
guard
let certificate = SecCertificateCreateWithData(nil, data as CFData),
let publicKey = SecCertificateCopyKey(certificate)
else {
throw Failure.invalidKeyData
}

return publicKey
}

}
60 changes: 60 additions & 0 deletions WireAPI/Sources/WireAPI/Models/Backend/ProxySettings.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
//
// Wire
// Copyright (C) 2024 Wire Swiss GmbH
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see http://www.gnu.org/licenses/.
//

import Foundation

/// Proxy settings for communicating with a backend server.

public enum ProxySettings {

/// Settings for an unauthenticated proxy.

case unauthenticated(host: String, port: Int)

/// Settings for an authenticated proxy.

case authenticated(host: String, port: Int, username: String, password: String)

/// Dictionary to be used with `URLSessionConfiguration.connectionProxyDictionary`.

func proxyDictionary() -> [AnyHashable: Any] {
let socksEnable = "SOCKSEnable"
let socksProxy = "SOCKSProxy"
let socksPort = "SOCKSPort"

var result: [AnyHashable: Any] = [
socksEnable: 1,
kCFProxyTypeKey: kCFProxyTypeSOCKS,
kCFStreamPropertySOCKSVersion: kCFStreamSocketSOCKSVersion5
]

switch self {
case let .unauthenticated(host, port):
result[socksProxy] = host
result[socksPort] = port
case let .authenticated(host, port, username, password):
result[socksProxy] = host
result[socksPort] = port
result[kCFStreamPropertySOCKSUser] = username
result[kCFStreamPropertySOCKSPassword] = password
}

return result
}

}
98 changes: 98 additions & 0 deletions WireAPI/Sources/WireAPI/Models/Backend/ServerTrustValidator.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
//
// Wire
// Copyright (C) 2024 Wire Swiss GmbH
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see http://www.gnu.org/licenses/.
//

import Foundation
@preconcurrency import Security

struct ServerTrustValidator: Sendable {

enum Failure: Error, Equatable {
case evaluatingServerTrustFailed
case noPublicKeyOnServerTrust
case noMatchingPublicKey
}

private let pinnedKeys: [PinnedKey]

init(pinnedKeys: [PinnedKey]) {
self.pinnedKeys = pinnedKeys
}

/// Verifies the server `trust` for the given `host`.
///
/// - Parameter trust: The `SecTrust` of the server.
/// - Parameter host: The host of the server.
/// - Throws: An error if server certificate should not be trusted.
/// - Note: If no pinned keys are found for the `host`, the server certificate is trusted.

func validate(trust: SecTrust, host: String) async throws {
let matchingKeys = pinnedKeys.filter { $0.matches(host: host) }.map(\.key)

// If no keys are pinned for `host`, we trust the server certificate
guard !matchingKeys.isEmpty else { return }

try await Self.verifyServerCertificateTrusted(trust)

let publicKey = try Self.publicKeyAssociatedWithServerTrust(trust)

guard matchingKeys.contains(publicKey) else {
throw Failure.noMatchingPublicKey
}
}

// MARK: - Private

private static func verifyServerCertificateTrusted(_ serverTrust: SecTrust) async throws {
try await withCheckedThrowingContinuation { continuation in
// `SecTrustEvaluateAsyncWithError` requires the completion queue to be the same as the queue on which it
// is called.
let queue = DispatchQueue.global()
queue.async {
SecTrustEvaluateAsyncWithError(serverTrust, queue) { _, success, error in
if success {
continuation.resume()
} else {
print("Server trust evaluation failed: \(String(describing: error))")
continuation.resume(throwing: Failure.evaluatingServerTrustFailed)
}
}
}
}
}

/// Returns the public key of the leaf certificate associated with `serverTrust`.
///
/// - Parameter serverTrust: SecTrust of server
/// - Returns: public key from `serverTrust`

private static func publicKeyAssociatedWithServerTrust(_ serverTrust: SecTrust) throws -> SecKey {
let certificates = SecTrustCopyCertificateChain(serverTrust) ?? [] as CFArray
let policy = SecPolicyCreateBasicX509()
var secTrust: SecTrust?

guard SecTrustCreateWithCertificates(certificates, policy, &secTrust) == noErr,
let trust = secTrust,
let result = SecTrustCopyKey(trust)
else {
throw Failure.noPublicKeyOnServerTrust
}

return result
}

}
Loading

0 comments on commit 9f69bb9

Please sign in to comment.