Fix browser mTLS client certificate challenges (#7040)

* Add failing browser client certificate auth tests

* Handle browser client certificate challenges

* Log browser client certificate keychain failures

* Require confirmation before sending client certificates

* Disambiguate client certificate picker labels

* Harden browser client certificate prompts

* Localize client certificate picker strings

* Reduce browser auth delegate growth

* Skip keychain auth UI during cert lookup

* Fail noninteractive keychain cert lookup

* Address mTLS certificate review edge cases

* Address client certificate review feedback

* Own browser auth prompt text formatting

* Fix client certificate warning budget

* Move browser client certificate logic into CmuxBrowser

* Require consent before using client certificates

* Cancel client certificate lookups with prompts

* Bridge client certificate lookup cancellation

* Clean client certificate auth policy issues

* Restore bundle-specific debug keychain group
This commit is contained in:
Austin Wang
2026-06-29 15:51:10 -07:00
committed by GitHub
parent 5eb7cdaf6c
commit a70fcdd4ef
24 changed files with 2392 additions and 123 deletions
@@ -0,0 +1,112 @@
public import Foundation
/// Resolves a WebKit client-certificate challenge into a challenge disposition.
@MainActor public struct BrowserClientCertificateAuthenticationHandler {
/// Cancels an in-flight client-certificate candidate lookup.
public typealias CandidateLookupCancellation = @MainActor @Sendable () -> Void
/// Asynchronously provides Keychain credential candidates for a protection space.
///
/// Return a cancellation closure when the lookup starts work that can outlive
/// the prompt request.
public typealias CandidateProvider = @MainActor @Sendable (
_ protectionSpace: URLProtectionSpace,
_ completion: @escaping @MainActor @Sendable ([BrowserClientCertificateCredentialCandidate]) -> Void
) -> CandidateLookupCancellation?
/// Registers a callback that dismisses any in-flight certificate picker.
public typealias PromptCancellationRegistration = (@escaping () -> Void) -> Void
/// Presents candidates and returns the selected candidate, or `nil` on cancellation.
public typealias CandidatePicker = (
_ protectionSpace: URLProtectionSpace,
_ candidates: [BrowserClientCertificateCredentialCandidate],
_ completion: @escaping (BrowserClientCertificateCredentialCandidate?) -> Void,
_ registerCancelPrompt: @escaping PromptCancellationRegistration
) -> Void
/// Returns whether the prompt request was canceled while lookup work was in flight.
public typealias PromptCancellationCheck = @MainActor () -> Bool
/// The completion shape expected by WebKit authentication-challenge delegates.
public typealias Completion = (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
private let candidateProvider: CandidateProvider
/// Creates a client-certificate challenge handler.
/// - Parameter candidateProvider: Provider used to look up matching client-certificate candidates.
public init(candidateProvider: @escaping CandidateProvider) {
self.candidateProvider = candidateProvider
}
/// Handles a client-certificate challenge when applicable.
/// - Parameters:
/// - challenge: The WebKit authentication challenge.
/// - candidatePicker: Picker used only when multiple candidates match.
/// - registerCancelPrompt: Callback registration used to dismiss an active picker.
/// - completionHandler: WebKit completion handler for the challenge.
/// - Returns: `true` when the challenge is a client-certificate challenge and was claimed.
@discardableResult
public func handle(
challenge: URLAuthenticationChallenge,
candidatePicker: CandidatePicker? = nil,
registerCancelPrompt: @escaping PromptCancellationRegistration = { _ in },
isCancelled: @escaping PromptCancellationCheck = { false },
completionHandler: @escaping Completion
) -> Bool {
guard challenge.isBrowserClientCertificateChallenge else {
return false
}
let cancelLookup = candidateProvider(challenge.protectionSpace) { candidates in
guard !isCancelled() else { return }
complete(
candidates: candidates,
protectionSpace: challenge.protectionSpace,
candidatePicker: candidatePicker,
registerCancelPrompt: registerCancelPrompt,
completionHandler: completionHandler
)
}
if let cancelLookup {
registerCancelPrompt {
MainActor.assumeIsolated {
cancelLookup()
}
}
}
return true
}
private func complete(
candidates: [BrowserClientCertificateCredentialCandidate],
protectionSpace: URLProtectionSpace,
candidatePicker: CandidatePicker?,
registerCancelPrompt: @escaping PromptCancellationRegistration,
completionHandler: @escaping Completion
) {
switch candidates.count {
case 0:
completionHandler(.performDefaultHandling, nil)
default:
guard let candidatePicker else {
completionHandler(.performDefaultHandling, nil)
return
}
candidatePicker(
protectionSpace,
candidates,
{ selectedCandidate in
guard let selectedCandidate else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
completionHandler(.useCredential, selectedCandidate.credential)
},
{ cancelPrompt in
registerCancelPrompt(cancelPrompt)
}
)
}
}
}
@@ -0,0 +1,34 @@
public import Foundation
/// A Keychain client-certificate identity that can satisfy a WebKit mTLS challenge.
///
/// The candidate carries the credential WebKit needs plus sanitized-by-caller
/// display metadata for the certificate picker.
///
/// - Safety: `URLCredential` is created once for an auth challenge and then
/// transferred to the main-actor WebKit completion callback without mutation.
public struct BrowserClientCertificateCredentialCandidate: @unchecked Sendable {
/// The certificate subject summary, if Keychain exposes one.
public let title: String?
/// The raw certificate serial number rendered as uppercase hexadecimal.
public let serialNumber: String?
/// The credential to pass back to WebKit when this candidate is selected.
public let credential: URLCredential
/// Creates a client-certificate credential candidate.
/// - Parameters:
/// - title: The certificate subject summary, if available.
/// - serialNumber: The raw certificate serial number as displayable text.
/// - credential: The WebKit credential backed by a Keychain identity.
public init(
title: String? = nil,
serialNumber: String? = nil,
credential: URLCredential
) {
self.title = title
self.serialNumber = serialNumber
self.credential = credential
}
}
@@ -0,0 +1,243 @@
public import Foundation
import LocalAuthentication
import OSLog
import Security
/// Looks up macOS Keychain identities that can answer browser client-certificate challenges.
public struct BrowserClientCertificateCredentialStore {
private static let tlsClientAuthenticationEKU = Data([
0x2B, 0x06, 0x01, 0x05, 0x05, 0x07, 0x03, 0x02,
])
private static let anyExtendedKeyUsageEKU = Data([
0x55, 0x1D, 0x25, 0x00,
])
private let logger = Logger(
subsystem: "com.cmuxterm.app",
category: "BrowserClientCertificate"
)
/// Creates a Keychain-backed credential store.
public init() {}
/// Looks up candidates from the macOS Keychain without blocking the main actor.
/// - Parameters:
/// - protectionSpace: The WebKit protection space from the client-certificate challenge.
/// - completion: Main-actor callback receiving matching candidates.
/// - Returns: A cancellation callback for the in-flight lookup task.
public func lookupCandidates(
protectionSpace: URLProtectionSpace,
completion: @escaping @MainActor @Sendable ([BrowserClientCertificateCredentialCandidate]) -> Void
) -> BrowserClientCertificateAuthenticationHandler.CandidateLookupCancellation {
let acceptedIssuers = protectionSpace.distinguishedNames
let lookupTask = Task.detached(priority: .userInitiated) {
let candidates = BrowserClientCertificateCredentialStore().candidates(acceptedIssuers: acceptedIssuers)
guard !Task.isCancelled else { return }
await MainActor.run {
guard !Task.isCancelled else { return }
completion(candidates)
}
}
return {
lookupTask.cancel()
}
}
/// Returns credential candidates matching the server's accepted issuers.
/// - Parameter protectionSpace: The WebKit protection space from the client-certificate challenge.
/// - Returns: Client-certificate candidates, or an empty array when none can be used.
public func candidates(for protectionSpace: URLProtectionSpace) -> [BrowserClientCertificateCredentialCandidate] {
candidates(acceptedIssuers: protectionSpace.distinguishedNames)
}
/// Returns credential candidates for the accepted issuer distinguished names.
/// - Parameter acceptedIssuers: DER-encoded issuer names advertised by the server, or `nil`/empty when omitted.
/// - Returns: Client-certificate candidates, or an empty array when none can be used.
public func candidates(acceptedIssuers: [Data]?) -> [BrowserClientCertificateCredentialCandidate] {
let query = identityLookupQuery(acceptedIssuers: acceptedIssuers)
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let result else {
if status == errSecInteractionNotAllowed {
logger.info(
"browser.clientCertificate.identityLookupSkipped reason=interactionNotAllowed"
)
} else if status != errSecItemNotFound {
logger.error(
"browser.clientCertificate.identityLookup status=\(status, privacy: .public)"
)
}
return []
}
return identities(from: result).compactMap(candidate(for:))
}
func identityLookupQuery(for protectionSpace: URLProtectionSpace) -> [String: Any] {
identityLookupQuery(acceptedIssuers: protectionSpace.distinguishedNames)
}
func identityLookupQuery(acceptedIssuers: [Data]?) -> [String: Any] {
var query: [String: Any] = [
kSecClass as String: kSecClassIdentity,
kSecReturnRef as String: true,
kSecMatchLimit as String: kSecMatchLimitAll,
kSecUseAuthenticationContext as String: noninteractiveAuthenticationContext(),
]
if let acceptedIssuers, !acceptedIssuers.isEmpty {
query[kSecMatchIssuers as String] = acceptedIssuers as CFArray
}
return query
}
func extendedKeyUsageAllowsTLSClientAuthentication(_ value: Any?) -> Bool {
guard let value else {
return true
}
var foundExtendedKeyUsage = false
var allowsTLSClientAuthentication = false
func collectOIDValues(from value: Any) {
if let data = value as? Data {
foundExtendedKeyUsage = true
if data == Self.tlsClientAuthenticationEKU
|| data == Self.anyExtendedKeyUsageEKU {
allowsTLSClientAuthentication = true
}
return
}
if let string = value as? String {
foundExtendedKeyUsage = true
switch string {
case "1.3.6.1.5.5.7.3.2", "2.5.29.37.0":
allowsTLSClientAuthentication = true
default:
break
}
return
}
if let dictionary = value as? [String: Any] {
if let nestedValue = dictionary[kSecPropertyKeyValue as String] {
collectOIDValues(from: nestedValue)
}
return
}
if let array = value as? [Any] {
for nestedValue in array {
collectOIDValues(from: nestedValue)
}
}
}
collectOIDValues(from: value)
return foundExtendedKeyUsage && allowsTLSClientAuthentication
}
private func noninteractiveAuthenticationContext() -> LAContext {
let context = LAContext()
context.interactionNotAllowed = true
return context
}
private func identities(from result: CFTypeRef) -> [SecIdentity] {
if CFGetTypeID(result) == SecIdentityGetTypeID() {
return [result as! SecIdentity]
}
guard CFGetTypeID(result) == CFArrayGetTypeID(),
let values = result as? [Any] else {
return []
}
return values.compactMap { value in
let cfValue = value as CFTypeRef
guard CFGetTypeID(cfValue) == SecIdentityGetTypeID() else { return nil }
return (cfValue as! SecIdentity)
}
}
private func candidate(for identity: SecIdentity) -> BrowserClientCertificateCredentialCandidate? {
var certificate: SecCertificate?
let status = SecIdentityCopyCertificate(identity, &certificate)
guard status == errSecSuccess, let certificate else {
logger.error(
"browser.clientCertificate.copyCertificate status=\(status, privacy: .public)"
)
return nil
}
guard certificateAllowsTLSClientAuthentication(certificate) else {
logger.info(
"browser.clientCertificate.identityFiltered reason=extendedKeyUsage"
)
return nil
}
let credential = URLCredential(
identity: identity,
certificates: [certificate],
persistence: .forSession
)
return BrowserClientCertificateCredentialCandidate(
title: SecCertificateCopySubjectSummary(certificate) as String?,
serialNumber: certificateSerialNumber(for: certificate),
credential: credential
)
}
private func certificateAllowsTLSClientAuthentication(_ certificate: SecCertificate) -> Bool {
var error: Unmanaged<CFError>?
guard let values = SecCertificateCopyValues(
certificate,
[kSecOIDExtendedKeyUsage] as CFArray,
&error
) as? [String: Any] else {
if let error {
logger.error(
"browser.clientCertificate.copyExtendedKeyUsage error=\((error.takeRetainedValue() as any Error).localizedDescription, privacy: .public)"
)
return false
}
return true
}
guard let extendedKeyUsage = values[kSecOIDExtendedKeyUsage as String] else {
return true
}
if let dictionary = extendedKeyUsage as? [String: Any],
let value = dictionary[kSecPropertyKeyValue as String] {
return extendedKeyUsageAllowsTLSClientAuthentication(value)
}
return extendedKeyUsageAllowsTLSClientAuthentication(extendedKeyUsage)
}
private func certificateSerialNumber(for certificate: SecCertificate) -> String? {
var error: Unmanaged<CFError>?
guard let serialNumberData = SecCertificateCopySerialNumberData(certificate, &error) as Data? else {
return nil
}
let serialNumber = hexString(for: serialNumberData)
return serialNumber.isEmpty ? nil : serialNumber
}
private func hexString(for data: Data) -> String {
let digits = Array("0123456789ABCDEF".utf8)
var output = [UInt8]()
output.reserveCapacity(data.count * 2)
for byte in data {
output.append(digits[Int(byte >> 4)])
output.append(digits[Int(byte & 0x0F)])
}
return String(decoding: output, as: UTF8.self)
}
}
@@ -0,0 +1,153 @@
public import Foundation
/// Coordinates client-certificate prompts so repeated WebKit challenges do not stack dialogs.
@MainActor public final class BrowserClientCertificatePromptCoordinator {
/// The completion shape expected by WebKit authentication-challenge delegates.
public typealias Completion = BrowserClientCertificateAuthenticationHandler.Completion
/// Registers a callback that dismisses any in-flight certificate picker.
public typealias PromptCancellationRegistration =
BrowserClientCertificateAuthenticationHandler.PromptCancellationRegistration
/// Returns whether the prompt request was canceled while lookup work was in flight.
public typealias PromptCancellationCheck =
BrowserClientCertificateAuthenticationHandler.PromptCancellationCheck
private static let maxQueuedProtectionSpaces = 4
private static let maxCompletionsPerProtectionSpace = 8
private var activeRequest: BrowserClientCertificatePromptRequest?
private var queuedRequests: [BrowserClientCertificatePromptRequest] = []
private var isCancelling = false
/// Creates an empty client-certificate prompt coordinator.
public init() {}
/// Handles or queues a client-certificate challenge.
/// - Parameters:
/// - challenge: The WebKit authentication challenge.
/// - startPrompt: Closure that starts the prompt flow for a protection space.
/// - completionHandler: WebKit completion handler for the challenge.
/// - Returns: `true` when the challenge is a client-certificate challenge and was claimed.
@discardableResult
public func handle(
challenge: URLAuthenticationChallenge,
startPrompt: @escaping (
@escaping Completion,
@escaping PromptCancellationRegistration,
@escaping PromptCancellationCheck
) -> Bool,
completionHandler: @escaping Completion
) -> Bool {
guard challenge.isBrowserClientCertificateChallenge else {
return false
}
guard !isCancelling else {
completionHandler(.cancelAuthenticationChallenge, nil)
return true
}
let key = BrowserClientCertificateProtectionSpaceKey(challenge.protectionSpace)
if let activeRequest, activeRequest.key == key {
append(completionHandler, to: activeRequest)
return true
}
if let queuedRequest = queuedRequests.first(where: { $0.key == key }) {
append(completionHandler, to: queuedRequest)
return true
}
let request = BrowserClientCertificatePromptRequest(
key: key,
startPrompt: startPrompt,
completion: completionHandler
)
if activeRequest == nil {
start(request)
} else if queuedRequests.count < Self.maxQueuedProtectionSpaces {
queuedRequests.append(request)
} else {
completionHandler(.cancelAuthenticationChallenge, nil)
}
return true
}
/// Cancels active and queued prompts.
/// - Parameter allowFuturePrompts: Whether future prompts are allowed after cancellation completes.
public func cancelAll(allowFuturePrompts: Bool = false) {
isCancelling = true
let active = activeRequest
activeRequest = nil
let queued = queuedRequests
queuedRequests.removeAll()
active?.cancelPromptIfNeeded()
active?.complete(disposition: .cancelAuthenticationChallenge, credential: nil)
queued.forEach {
$0.complete(disposition: .cancelAuthenticationChallenge, credential: nil)
}
if allowFuturePrompts {
isCancelling = false
}
}
private func append(_ completion: @escaping Completion, to request: BrowserClientCertificatePromptRequest) {
guard request.completionCount < Self.maxCompletionsPerProtectionSpace else {
completion(.cancelAuthenticationChallenge, nil)
return
}
request.appendCompletion(completion)
}
private func start(_ request: BrowserClientCertificatePromptRequest) {
guard !isCancelling else {
request.complete(disposition: .cancelAuthenticationChallenge, credential: nil)
return
}
activeRequest = request
let started = request.startPrompt(
{ [weak self, weak request] disposition, credential in
guard let request else { return }
guard let self else {
request.complete(disposition: disposition, credential: credential)
return
}
if self.activeRequest === request {
self.activeRequest = nil
}
request.complete(disposition: disposition, credential: credential)
self.startNext()
},
{ [weak request] cancelPrompt in
request?.setCancelPrompt(cancelPrompt)
},
{ [weak request] in
request?.isCancelled ?? true
}
)
if !started {
if activeRequest === request {
activeRequest = nil
}
request.complete(disposition: .performDefaultHandling, credential: nil)
startNext()
}
}
private func startNext() {
guard activeRequest == nil else { return }
guard !isCancelling else {
let queued = queuedRequests
queuedRequests.removeAll()
queued.forEach {
$0.complete(disposition: .cancelAuthenticationChallenge, credential: nil)
}
return
}
guard !queuedRequests.isEmpty else { return }
start(queuedRequests.removeFirst())
}
}
@@ -0,0 +1,60 @@
import Foundation
@MainActor final class BrowserClientCertificatePromptRequest {
typealias Completion = BrowserClientCertificateAuthenticationHandler.Completion
typealias PromptCancellation = () -> Void
typealias PromptCancellationRegistration = BrowserClientCertificateAuthenticationHandler.PromptCancellationRegistration
typealias PromptCancellationCheck = BrowserClientCertificateAuthenticationHandler.PromptCancellationCheck
let key: BrowserClientCertificateProtectionSpaceKey
let startPrompt: (
@escaping Completion,
@escaping PromptCancellationRegistration,
@escaping PromptCancellationCheck
) -> Bool
private var completions: [Completion]
private var cancelPrompts: [PromptCancellation] = []
private(set) var isCancelled = false
init(
key: BrowserClientCertificateProtectionSpaceKey,
startPrompt: @escaping (
@escaping Completion,
@escaping PromptCancellationRegistration,
@escaping PromptCancellationCheck
) -> Bool,
completion: @escaping Completion
) {
self.key = key
self.startPrompt = startPrompt
self.completions = [completion]
}
var completionCount: Int {
completions.count
}
func appendCompletion(_ completion: @escaping Completion) {
completions.append(completion)
}
func setCancelPrompt(_ cancelPrompt: @escaping PromptCancellation) {
cancelPrompts.append(cancelPrompt)
}
func cancelPromptIfNeeded() {
isCancelled = true
let cancelPrompts = cancelPrompts
self.cancelPrompts.removeAll()
cancelPrompts.forEach { $0() }
}
func complete(
disposition: URLSession.AuthChallengeDisposition,
credential: URLCredential?
) {
let callbacks = completions
completions.removeAll()
callbacks.forEach { $0(disposition, credential) }
}
}
@@ -0,0 +1,33 @@
import Foundation
struct BrowserClientCertificateProtectionSpaceKey: Hashable {
let host: String
let port: Int
let protocolName: String?
let distinguishedNames: [Data]?
let authenticationMethod: String
init(_ protectionSpace: URLProtectionSpace) {
self.init(
host: protectionSpace.host,
port: protectionSpace.port,
protocolName: protectionSpace.`protocol`,
distinguishedNames: protectionSpace.distinguishedNames,
authenticationMethod: protectionSpace.authenticationMethod
)
}
init(
host: String,
port: Int,
protocolName: String?,
distinguishedNames: [Data]?,
authenticationMethod: String
) {
self.host = host
self.port = port
self.protocolName = protocolName
self.distinguishedNames = (distinguishedNames?.isEmpty == false) ? distinguishedNames : nil
self.authenticationMethod = authenticationMethod
}
}
@@ -0,0 +1,7 @@
import Foundation
extension URLAuthenticationChallenge {
var isBrowserClientCertificateChallenge: Bool {
protectionSpace.authenticationMethod == NSURLAuthenticationMethodClientCertificate
}
}
@@ -0,0 +1,9 @@
import Foundation
final class BrowserAuthChallengeSenderStub: NSObject, URLAuthenticationChallengeSender {
func use(_ credential: URLCredential, for challenge: URLAuthenticationChallenge) {}
func continueWithoutCredential(for challenge: URLAuthenticationChallenge) {}
func cancel(_ challenge: URLAuthenticationChallenge) {}
func performDefaultHandling(for challenge: URLAuthenticationChallenge) {}
func rejectProtectionSpaceAndContinue(with challenge: URLAuthenticationChallenge) {}
}
@@ -0,0 +1,417 @@
import Foundation
import LocalAuthentication
import Security
import Testing
@testable import CmuxBrowser
@MainActor @Suite
struct BrowserClientCertificateAuthenticationHandlerTests {
private func makeChallenge(
authenticationMethod: String = NSURLAuthenticationMethodClientCertificate
) -> URLAuthenticationChallenge {
let protectionSpace = URLProtectionSpace(
host: "client.badssl.com",
port: 443,
protocol: "https",
realm: nil,
authenticationMethod: authenticationMethod
)
return URLAuthenticationChallenge(
protectionSpace: protectionSpace,
proposedCredential: nil,
previousFailureCount: 0,
failureResponse: nil,
error: nil,
sender: BrowserAuthChallengeSenderStub()
)
}
private func makeProtectionSpace(
host: String,
port: Int = 443,
protocolName: String = "https"
) -> URLProtectionSpace {
URLProtectionSpace(
host: host,
port: port,
protocol: protocolName,
realm: nil,
authenticationMethod: NSURLAuthenticationMethodClientCertificate
)
}
@Test
func identityLookupQueryAllowsMissingAcceptedCertificateIssuers() {
let query = BrowserClientCertificateCredentialStore().identityLookupQuery(
for: makeProtectionSpace(host: "mtls.example")
)
#expect(query[kSecClass as String] as? String == kSecClassIdentity as String)
#expect(query[kSecReturnRef as String] as? Bool == true)
#expect(query[kSecMatchLimit as String] as? String == kSecMatchLimitAll as String)
#expect(query[kSecMatchIssuers as String] == nil)
}
@Test
func identityLookupQueryDisallowsKeychainAuthenticationUI() throws {
let acceptedIssuer = Data([0x30, 0x03, 0x31, 0x01, 0x30])
let query = BrowserClientCertificateCredentialStore().identityLookupQuery(
acceptedIssuers: [acceptedIssuer]
)
let context = try #require(query[kSecUseAuthenticationContext as String] as? LAContext)
let issuers = try #require(query[kSecMatchIssuers as String] as? [Data])
#expect(query[kSecClass as String] as? String == kSecClassIdentity as String)
#expect(query[kSecReturnRef as String] as? Bool == true)
#expect(query[kSecMatchLimit as String] as? String == kSecMatchLimitAll as String)
#expect(issuers == [acceptedIssuer])
#expect(context.interactionNotAllowed)
#expect(query[kSecUseAuthenticationUI as String] == nil)
}
@Test
func protectionSpaceKeyTreatsNilAndEmptyIssuersAsEquivalent() {
let nilIssuerKey = BrowserClientCertificateProtectionSpaceKey(
host: "mtls.example",
port: 443,
protocolName: "https",
distinguishedNames: nil,
authenticationMethod: NSURLAuthenticationMethodClientCertificate
)
let emptyIssuerKey = BrowserClientCertificateProtectionSpaceKey(
host: "mtls.example",
port: 443,
protocolName: "https",
distinguishedNames: [],
authenticationMethod: NSURLAuthenticationMethodClientCertificate
)
#expect(nilIssuerKey == emptyIssuerKey)
#expect(nilIssuerKey.distinguishedNames == nil)
#expect(emptyIssuerKey.distinguishedNames == nil)
}
@Test
func usesPickerSelectionWhenOneClientCertificateCandidateExists() throws {
let expectedCredential = URLCredential(
user: "client-cert",
password: "unused",
persistence: .forSession
)
let handler = BrowserClientCertificateAuthenticationHandler { _, completion in
completion([
BrowserClientCertificateCredentialCandidate(
title: "BadSSL Client Certificate",
credential: expectedCredential
),
])
return nil
}
var disposition: URLSession.AuthChallengeDisposition?
var credential: URLCredential?
var pickerWasPresented = false
let handled = handler.handle(
challenge: makeChallenge(),
candidatePicker: { _, candidates, completion, _ in
pickerWasPresented = true
completion(candidates[0])
}
) { returnedDisposition, returnedCredential in
disposition = returnedDisposition
credential = returnedCredential
}
#expect(handled)
#expect(pickerWasPresented)
#expect(disposition == .useCredential)
let returnedCredential = try #require(credential)
#expect(returnedCredential === expectedCredential)
}
@Test
func performsDefaultHandlingWhenCandidatesExistWithoutPicker() {
let candidates = [
BrowserClientCertificateCredentialCandidate(
credential: URLCredential(user: "first", password: "password", persistence: .forSession)
),
BrowserClientCertificateCredentialCandidate(
credential: URLCredential(user: "second", password: "password", persistence: .forSession)
),
]
let handler = BrowserClientCertificateAuthenticationHandler { _, completion in
completion(candidates)
return nil
}
var disposition: URLSession.AuthChallengeDisposition?
var credential: URLCredential?
let handled = handler.handle(challenge: makeChallenge()) { returnedDisposition, returnedCredential in
disposition = returnedDisposition
credential = returnedCredential
}
#expect(handled)
#expect(disposition == .performDefaultHandling)
#expect(credential == nil)
}
@Test
func performsDefaultHandlingWhenNoClientCertificateCandidateExists() {
let handler = BrowserClientCertificateAuthenticationHandler { _, completion in
completion([])
return nil
}
var disposition: URLSession.AuthChallengeDisposition?
var credential: URLCredential?
let handled = handler.handle(challenge: makeChallenge()) { returnedDisposition, returnedCredential in
disposition = returnedDisposition
credential = returnedCredential
}
#expect(handled)
#expect(disposition == .performDefaultHandling)
#expect(credential == nil)
}
@Test
func ignoresNonClientCertificateChallenges() {
let handler = BrowserClientCertificateAuthenticationHandler { _, completion in
completion([
BrowserClientCertificateCredentialCandidate(
credential: URLCredential(user: "user", password: "password", persistence: .forSession)
),
])
return nil
}
var completionCalled = false
let handled = handler.handle(
challenge: makeChallenge(authenticationMethod: NSURLAuthenticationMethodServerTrust)
) { _, _ in
completionCalled = true
}
#expect(!handled)
#expect(!completionCalled)
}
@Test
func usesPickerSelectionWhenMultipleClientCertificateCandidatesExist() throws {
let firstCredential = URLCredential(user: "first", password: "unused", persistence: .forSession)
let secondCredential = URLCredential(user: "second", password: "unused", persistence: .forSession)
let candidates = [
BrowserClientCertificateCredentialCandidate(title: "First", credential: firstCredential),
BrowserClientCertificateCredentialCandidate(title: "Second", credential: secondCredential),
]
let handler = BrowserClientCertificateAuthenticationHandler { _, completion in
completion(candidates)
return nil
}
var pickerCandidateCount: Int?
var disposition: URLSession.AuthChallengeDisposition?
var credential: URLCredential?
let handled = handler.handle(
challenge: makeChallenge(),
candidatePicker: { _, presentedCandidates, completion, _ in
pickerCandidateCount = presentedCandidates.count
completion(presentedCandidates[1])
}
) { returnedDisposition, returnedCredential in
disposition = returnedDisposition
credential = returnedCredential
}
#expect(handled)
#expect(pickerCandidateCount == 2)
#expect(disposition == .useCredential)
let returnedCredential = try #require(credential)
#expect(returnedCredential === secondCredential)
}
@Test
func coordinatorCoalescesDuplicateProtectionSpaceChallenges() throws {
let expectedCredential = URLCredential(user: "client-cert", password: "unused", persistence: .forSession)
let coordinator = BrowserClientCertificatePromptCoordinator()
let challenge = makeChallenge()
var promptCompletions: [BrowserClientCertificatePromptCoordinator.Completion] = []
var firstDisposition: URLSession.AuthChallengeDisposition?
var firstCredential: URLCredential?
var secondDisposition: URLSession.AuthChallengeDisposition?
var secondCredential: URLCredential?
let handledFirstChallenge = coordinator.handle(
challenge: challenge,
startPrompt: { finishPrompt, _, _ in
promptCompletions.append(finishPrompt)
return true
}
) { disposition, credential in
firstDisposition = disposition
firstCredential = credential
}
#expect(handledFirstChallenge)
let handledSecondChallenge = coordinator.handle(
challenge: challenge,
startPrompt: { finishPrompt, _, _ in
promptCompletions.append(finishPrompt)
return true
}
) { disposition, credential in
secondDisposition = disposition
secondCredential = credential
}
#expect(handledSecondChallenge)
#expect(promptCompletions.count == 1)
let promptCompletion = try #require(promptCompletions.first)
promptCompletion(.useCredential, expectedCredential)
#expect(firstDisposition == .useCredential)
#expect(firstCredential === expectedCredential)
#expect(secondDisposition == .useCredential)
#expect(secondCredential === expectedCredential)
}
@Test
func coordinatorBoundsQueuedProtectionSpaces() {
let coordinator = BrowserClientCertificatePromptCoordinator()
var promptStartCount = 0
var overflowDisposition: URLSession.AuthChallengeDisposition?
func startPrompt(
_ finishPrompt: @escaping BrowserClientCertificatePromptCoordinator.Completion,
_ registerCancelPrompt: @escaping BrowserClientCertificatePromptCoordinator.PromptCancellationRegistration,
_ isCancelled: @escaping BrowserClientCertificatePromptCoordinator.PromptCancellationCheck
) -> Bool {
_ = finishPrompt
_ = registerCancelPrompt
_ = isCancelled
promptStartCount += 1
return true
}
for index in 0..<6 {
let challenge = URLAuthenticationChallenge(
protectionSpace: makeProtectionSpace(host: "mtls-\(index).example"),
proposedCredential: nil,
previousFailureCount: 0,
failureResponse: nil,
error: nil,
sender: BrowserAuthChallengeSenderStub()
)
let handled = coordinator.handle(
challenge: challenge,
startPrompt: startPrompt
) { disposition, _ in
if index == 5 {
overflowDisposition = disposition
}
}
#expect(handled)
}
#expect(promptStartCount == 1)
#expect(overflowDisposition == .cancelAuthenticationChallenge)
}
@Test
func coordinatorCancelAllDismissesActivePromptBeforeCompletingChallenge() {
let coordinator = BrowserClientCertificatePromptCoordinator()
var cancelPromptCalled = false
var completionCount = 0
var disposition: URLSession.AuthChallengeDisposition?
let handledChallenge = coordinator.handle(
challenge: makeChallenge(),
startPrompt: { finishPrompt, registerCancelPrompt, _ in
registerCancelPrompt {
cancelPromptCalled = true
finishPrompt(.cancelAuthenticationChallenge, nil)
}
return true
}
) { returnedDisposition, _ in
completionCount += 1
disposition = returnedDisposition
}
#expect(handledChallenge)
coordinator.cancelAll()
#expect(cancelPromptCalled)
#expect(completionCount == 1)
#expect(disposition == .cancelAuthenticationChallenge)
}
@Test
func cancelledLookupDoesNotPresentStalePicker() {
let coordinator = BrowserClientCertificatePromptCoordinator()
var lookupCompletion: (@MainActor @Sendable ([BrowserClientCertificateCredentialCandidate]) -> Void)?
var lookupCancelled = false
let handler = BrowserClientCertificateAuthenticationHandler { _, completion in
lookupCompletion = completion
return {
lookupCancelled = true
}
}
var pickerWasPresented = false
var completionCount = 0
var disposition: URLSession.AuthChallengeDisposition?
let challenge = makeChallenge()
let handled = coordinator.handle(
challenge: challenge,
startPrompt: { finishPrompt, registerCancelPrompt, isCancelled in
handler.handle(
challenge: challenge,
candidatePicker: { _, candidates, completion, _ in
pickerWasPresented = true
completion(candidates.first)
},
registerCancelPrompt: registerCancelPrompt,
isCancelled: isCancelled,
completionHandler: finishPrompt
)
}
) { returnedDisposition, _ in
completionCount += 1
disposition = returnedDisposition
}
#expect(handled)
coordinator.cancelAll()
#expect(lookupCancelled)
lookupCompletion?([
BrowserClientCertificateCredentialCandidate(
credential: URLCredential(user: "first", password: "unused", persistence: .forSession)
),
BrowserClientCertificateCredentialCandidate(
credential: URLCredential(user: "second", password: "unused", persistence: .forSession)
),
])
#expect(!pickerWasPresented)
#expect(completionCount == 1)
#expect(disposition == .cancelAuthenticationChallenge)
}
@Test
func extendedKeyUsageAllowsOnlyTLSClientAuthentication() {
let clientAuthenticationOID = Data([0x2B, 0x06, 0x01, 0x05, 0x05, 0x07, 0x03, 0x02])
let serverAuthenticationOID = Data([0x2B, 0x06, 0x01, 0x05, 0x05, 0x07, 0x03, 0x01])
let anyExtendedKeyUsageOID = Data([0x55, 0x1D, 0x25, 0x00])
let store = BrowserClientCertificateCredentialStore()
#expect(store.extendedKeyUsageAllowsTLSClientAuthentication(nil))
#expect(store.extendedKeyUsageAllowsTLSClientAuthentication([clientAuthenticationOID]))
#expect(store.extendedKeyUsageAllowsTLSClientAuthentication([anyExtendedKeyUsageOID]))
#expect(!store.extendedKeyUsageAllowsTLSClientAuthentication([serverAuthenticationOID]))
#expect(!store.extendedKeyUsageAllowsTLSClientAuthentication([]))
}
}
+875
View File
@@ -20633,6 +20633,881 @@
}
}
},
"browser.dialog.clientCertificate.continue": {
"extractionState": "manual",
"localizations": {
"ar": {
"stringUnit": {
"state": "translated",
"value": "متابعة"
}
},
"bs": {
"stringUnit": {
"state": "translated",
"value": "Nastavi"
}
},
"da": {
"stringUnit": {
"state": "translated",
"value": "Fortsæt"
}
},
"de": {
"stringUnit": {
"state": "translated",
"value": "Fortfahren"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Continue"
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "Continuar"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Continuer"
}
},
"it": {
"stringUnit": {
"state": "translated",
"value": "Continua"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "続行"
}
},
"km": {
"stringUnit": {
"state": "translated",
"value": "បន្ត"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "계속"
}
},
"nb": {
"stringUnit": {
"state": "translated",
"value": "Fortsett"
}
},
"pl": {
"stringUnit": {
"state": "translated",
"value": "Kontynuuj"
}
},
"pt-BR": {
"stringUnit": {
"state": "translated",
"value": "Continuar"
}
},
"ru": {
"stringUnit": {
"state": "translated",
"value": "Продолжить"
}
},
"th": {
"stringUnit": {
"state": "translated",
"value": "ดำเนินการต่อ"
}
},
"tr": {
"stringUnit": {
"state": "translated",
"value": "Devam Et"
}
},
"uk": {
"stringUnit": {
"state": "translated",
"value": "Продовжити"
}
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "继续"
}
},
"zh-Hant": {
"stringUnit": {
"state": "translated",
"value": "繼續"
}
}
}
},
"browser.dialog.clientCertificate.fallbackCertificateName": {
"extractionState": "manual",
"localizations": {
"ar": {
"stringUnit": {
"state": "translated",
"value": "الشهادة %d"
}
},
"bs": {
"stringUnit": {
"state": "translated",
"value": "Certifikat %d"
}
},
"da": {
"stringUnit": {
"state": "translated",
"value": "Certifikat %d"
}
},
"de": {
"stringUnit": {
"state": "translated",
"value": "Zertifikat %d"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Certificate %d"
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "Certificado %d"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Certificat %d"
}
},
"it": {
"stringUnit": {
"state": "translated",
"value": "Certificato %d"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "証明書 %d"
}
},
"km": {
"stringUnit": {
"state": "translated",
"value": "វិញ្ញាបនបត្រ %d"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "인증서 %d"
}
},
"nb": {
"stringUnit": {
"state": "translated",
"value": "Sertifikat %d"
}
},
"pl": {
"stringUnit": {
"state": "translated",
"value": "Certyfikat %d"
}
},
"pt-BR": {
"stringUnit": {
"state": "translated",
"value": "Certificado %d"
}
},
"ru": {
"stringUnit": {
"state": "translated",
"value": "Сертификат %d"
}
},
"th": {
"stringUnit": {
"state": "translated",
"value": "ใบรับรอง %d"
}
},
"tr": {
"stringUnit": {
"state": "translated",
"value": "Sertifika %d"
}
},
"uk": {
"stringUnit": {
"state": "translated",
"value": "Сертифікат %d"
}
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "证书 %d"
}
},
"zh-Hant": {
"stringUnit": {
"state": "translated",
"value": "憑證 %d"
}
}
}
},
"browser.dialog.clientCertificate.message": {
"extractionState": "manual",
"localizations": {
"ar": {
"stringUnit": {
"state": "translated",
"value": "يتطلب %@ شهادة عميل."
}
},
"bs": {
"stringUnit": {
"state": "translated",
"value": "%@ zahtijeva klijentski certifikat."
}
},
"da": {
"stringUnit": {
"state": "translated",
"value": "%@ kræver et klientcertifikat."
}
},
"de": {
"stringUnit": {
"state": "translated",
"value": "%@ erfordert ein Clientzertifikat."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "%@ requires a client certificate."
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "%@ requiere un certificado de cliente."
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "%@ nécessite un certificat client."
}
},
"it": {
"stringUnit": {
"state": "translated",
"value": "%@ richiede un certificato client."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "%@ にはクライアント証明書が必要です。"
}
},
"km": {
"stringUnit": {
"state": "translated",
"value": "%@ ត្រូវការវិញ្ញាបនបត្រអតិថិជន។"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "%@에 클라이언트 인증서가 필요합니다."
}
},
"nb": {
"stringUnit": {
"state": "translated",
"value": "%@ krever et klientsertifikat."
}
},
"pl": {
"stringUnit": {
"state": "translated",
"value": "%@ wymaga certyfikatu klienta."
}
},
"pt-BR": {
"stringUnit": {
"state": "translated",
"value": "%@ requer um certificado de cliente."
}
},
"ru": {
"stringUnit": {
"state": "translated",
"value": "Для %@ требуется клиентский сертификат."
}
},
"th": {
"stringUnit": {
"state": "translated",
"value": "%@ ต้องใช้ใบรับรองไคลเอนต์"
}
},
"tr": {
"stringUnit": {
"state": "translated",
"value": "%@ bir istemci sertifikası gerektirir."
}
},
"uk": {
"stringUnit": {
"state": "translated",
"value": "Для %@ потрібен клієнтський сертифікат."
}
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "%@ 需要客户端证书。"
}
},
"zh-Hant": {
"stringUnit": {
"state": "translated",
"value": "%@ 需要用戶端憑證。"
}
}
}
},
"browser.dialog.clientCertificate.serialNumber": {
"extractionState": "manual",
"localizations": {
"ar": {
"stringUnit": {
"state": "translated",
"value": "الرقم التسلسلي %@"
}
},
"bs": {
"stringUnit": {
"state": "translated",
"value": "Serijski broj %@"
}
},
"da": {
"stringUnit": {
"state": "translated",
"value": "Serienummer %@"
}
},
"de": {
"stringUnit": {
"state": "translated",
"value": "Seriennummer %@"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Serial %@"
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "Número de serie %@"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Numéro de série %@"
}
},
"it": {
"stringUnit": {
"state": "translated",
"value": "Numero di serie %@"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "シリアル番号 %@"
}
},
"km": {
"stringUnit": {
"state": "translated",
"value": "លេខស៊េរី %@"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "일련 번호 %@"
}
},
"nb": {
"stringUnit": {
"state": "translated",
"value": "Serienummer %@"
}
},
"pl": {
"stringUnit": {
"state": "translated",
"value": "Numer seryjny %@"
}
},
"pt-BR": {
"stringUnit": {
"state": "translated",
"value": "Número de série %@"
}
},
"ru": {
"stringUnit": {
"state": "translated",
"value": "Серийный номер %@"
}
},
"th": {
"stringUnit": {
"state": "translated",
"value": "หมายเลขซีเรียล %@"
}
},
"tr": {
"stringUnit": {
"state": "translated",
"value": "Seri %@"
}
},
"uk": {
"stringUnit": {
"state": "translated",
"value": "Серійний номер %@"
}
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "序列号 %@"
}
},
"zh-Hant": {
"stringUnit": {
"state": "translated",
"value": "序號 %@"
}
}
}
},
"browser.dialog.clientCertificate.title": {
"extractionState": "manual",
"localizations": {
"ar": {
"stringUnit": {
"state": "translated",
"value": "اختر شهادة"
}
},
"bs": {
"stringUnit": {
"state": "translated",
"value": "Odaberite certifikat"
}
},
"da": {
"stringUnit": {
"state": "translated",
"value": "Vælg et certifikat"
}
},
"de": {
"stringUnit": {
"state": "translated",
"value": "Zertifikat auswählen"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Choose a Certificate"
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "Elegir un certificado"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Choisir un certificat"
}
},
"it": {
"stringUnit": {
"state": "translated",
"value": "Scegli un certificato"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "証明書を選択"
}
},
"km": {
"stringUnit": {
"state": "translated",
"value": "ជ្រើសរើសវិញ្ញាបនបត្រ"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "인증서 선택"
}
},
"nb": {
"stringUnit": {
"state": "translated",
"value": "Velg et sertifikat"
}
},
"pl": {
"stringUnit": {
"state": "translated",
"value": "Wybierz certyfikat"
}
},
"pt-BR": {
"stringUnit": {
"state": "translated",
"value": "Escolher um certificado"
}
},
"ru": {
"stringUnit": {
"state": "translated",
"value": "Выберите сертификат"
}
},
"th": {
"stringUnit": {
"state": "translated",
"value": "เลือกใบรับรอง"
}
},
"tr": {
"stringUnit": {
"state": "translated",
"value": "Sertifika Seç"
}
},
"uk": {
"stringUnit": {
"state": "translated",
"value": "Виберіть сертифікат"
}
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "选择证书"
}
},
"zh-Hant": {
"stringUnit": {
"state": "translated",
"value": "選擇憑證"
}
}
}
},
"browser.dialog.clientCertificate.titleWithSubtitle": {
"extractionState": "manual",
"localizations": {
"ar": {
"stringUnit": {
"state": "translated",
"value": "%@ (%@)"
}
},
"bs": {
"stringUnit": {
"state": "translated",
"value": "%@ (%@)"
}
},
"da": {
"stringUnit": {
"state": "translated",
"value": "%@ (%@)"
}
},
"de": {
"stringUnit": {
"state": "translated",
"value": "%@ (%@)"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "%@ (%@)"
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "%@ (%@)"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "%@ (%@)"
}
},
"it": {
"stringUnit": {
"state": "translated",
"value": "%@ (%@)"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "%@%@"
}
},
"km": {
"stringUnit": {
"state": "translated",
"value": "%@ (%@)"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "%@ (%@)"
}
},
"nb": {
"stringUnit": {
"state": "translated",
"value": "%@ (%@)"
}
},
"pl": {
"stringUnit": {
"state": "translated",
"value": "%@ (%@)"
}
},
"pt-BR": {
"stringUnit": {
"state": "translated",
"value": "%@ (%@)"
}
},
"ru": {
"stringUnit": {
"state": "translated",
"value": "%@ (%@)"
}
},
"th": {
"stringUnit": {
"state": "translated",
"value": "%@ (%@)"
}
},
"tr": {
"stringUnit": {
"state": "translated",
"value": "%@ (%@)"
}
},
"uk": {
"stringUnit": {
"state": "translated",
"value": "%@ (%@)"
}
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "%@%@"
}
},
"zh-Hant": {
"stringUnit": {
"state": "translated",
"value": "%@%@"
}
}
}
},
"browser.dialog.clientCertificate.unknownHost": {
"extractionState": "manual",
"localizations": {
"ar": {
"stringUnit": {
"state": "translated",
"value": "هذا الموقع"
}
},
"bs": {
"stringUnit": {
"state": "translated",
"value": "Ova stranica"
}
},
"da": {
"stringUnit": {
"state": "translated",
"value": "Dette websted"
}
},
"de": {
"stringUnit": {
"state": "translated",
"value": "Diese Website"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "This site"
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "Este sitio"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Ce site"
}
},
"it": {
"stringUnit": {
"state": "translated",
"value": "Questo sito"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "このサイト"
}
},
"km": {
"stringUnit": {
"state": "translated",
"value": "គេហទំព័រនេះ"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "이 사이트"
}
},
"nb": {
"stringUnit": {
"state": "translated",
"value": "Dette nettstedet"
}
},
"pl": {
"stringUnit": {
"state": "translated",
"value": "Ta witryna"
}
},
"pt-BR": {
"stringUnit": {
"state": "translated",
"value": "Este site"
}
},
"ru": {
"stringUnit": {
"state": "translated",
"value": "Этот сайт"
}
},
"th": {
"stringUnit": {
"state": "translated",
"value": "ไซต์นี้"
}
},
"tr": {
"stringUnit": {
"state": "translated",
"value": "Bu site"
}
},
"uk": {
"stringUnit": {
"state": "translated",
"value": "Цей сайт"
}
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "此网站"
}
},
"zh-Hant": {
"stringUnit": {
"state": "translated",
"value": "此網站"
}
}
}
},
"browser.dialog.pageSays": {
"extractionState": "manual",
"localizations": {
+4 -5
View File
@@ -2,10 +2,9 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)$(CFBundleIdentifier)</string>
<string>$(CFBundleIdentifier)</string>
</array>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)$(PRODUCT_BUNDLE_IDENTIFIER)</string>
</array>
</dict>
</plist>
@@ -0,0 +1,93 @@
import Foundation
struct BrowserAuthPromptTextFormatter {
private static let defaultDangerousScalars: Set<Unicode.Scalar> = [
"\u{200B}", "\u{200C}", "\u{200D}", "\u{200E}", "\u{200F}",
"\u{202A}", "\u{202B}", "\u{202C}", "\u{202D}", "\u{202E}",
"\u{2066}", "\u{2067}", "\u{2068}", "\u{2069}",
"\u{FEFF}",
]
private let textMaxLength: Int
private let dangerousScalars: Set<Unicode.Scalar>
init(
textMaxLength: Int = 240,
dangerousScalars: Set<Unicode.Scalar> = Self.defaultDangerousScalars
) {
self.textMaxLength = textMaxLength
self.dangerousScalars = dangerousScalars
}
func filteredText(_ text: String) -> String {
let filtered = String(text.unicodeScalars.filter { scalar in
!dangerousScalars.contains(scalar)
&& !CharacterSet.controlCharacters.contains(scalar)
})
return filtered.trimmingCharacters(in: .whitespacesAndNewlines)
}
func sanitizedText(_ text: String) -> String {
let trimmed = filteredText(text)
guard trimmed.count > textMaxLength else {
return trimmed
}
return String(trimmed.prefix(textMaxLength))
}
func middleElidedText(_ text: String) -> String {
let trimmed = filteredText(text)
guard trimmed.count > textMaxLength else {
return trimmed
}
let marker = "..."
let keptCharacterCount = textMaxLength - marker.count
let prefixCount = min(48, max(16, keptCharacterCount / 3))
let suffixCount = max(0, keptCharacterCount - prefixCount)
return String(trimmed.prefix(prefixCount)) + marker + String(trimmed.suffix(suffixCount))
}
func defaultPort(forProtocol protocolName: String?) -> Int? {
switch protocolName?.lowercased() {
case "http":
return 80
case "https":
return 443
default:
return nil
}
}
func origin(
protectionSpace: URLProtectionSpace,
unknownHost: String
) -> String {
let host = filteredText(protectionSpace.host)
guard !host.isEmpty else {
return unknownHost
}
let rawProtocol = protectionSpace.`protocol` ?? ""
let protocolName = filteredText(rawProtocol).lowercased()
let defaultPort = defaultPort(forProtocol: protocolName)
let displayHost: String
if host.contains(":") && !host.hasPrefix("[") && !host.hasSuffix("]") {
displayHost = "[\(host)]"
} else {
displayHost = host
}
let port = protectionSpace.port
let authority: String
if port > 0, port != defaultPort {
authority = "\(displayHost):\(port)"
} else {
authority = displayHost
}
let origin = protocolName.isEmpty ? authority : "\(protocolName)://\(authority)"
return middleElidedText(origin)
}
}
@@ -0,0 +1,59 @@
import CmuxBrowser
import Foundation
import WebKit
@MainActor final class BrowserClientCertificateAuthenticationController {
private let promptCoordinator = BrowserClientCertificatePromptCoordinator()
private let authenticationHandler: BrowserClientCertificateAuthenticationHandler
init(
candidateProvider: @escaping BrowserClientCertificateAuthenticationHandler.CandidateProvider = {
protectionSpace,
completion in
BrowserClientCertificateCredentialStore().lookupCandidates(
protectionSpace: protectionSpace,
completion: completion
)
}
) {
authenticationHandler = BrowserClientCertificateAuthenticationHandler(
candidateProvider: candidateProvider
)
}
func cancelAll(allowFuturePrompts: Bool = false) {
promptCoordinator.cancelAll(allowFuturePrompts: allowFuturePrompts)
}
@discardableResult
func handle(
challenge: URLAuthenticationChallenge,
in webView: WKWebView,
presentAlert: @escaping BrowserAlertPresenter = browserPresentAlert,
completionHandler: @escaping BrowserClientCertificateAuthenticationHandler.Completion
) -> Bool {
promptCoordinator.handle(
challenge: challenge,
startPrompt: { [authenticationHandler, presentAlert] finishPrompt, registerCancelPrompt, isCancelled in
authenticationHandler.handle(
challenge: challenge,
candidatePicker: { [presentAlert] protectionSpace, candidates, completion, registerCancelPrompt in
BrowserClientCertificateCredentialPicker(
webView: webView,
presentAlert: presentAlert
).selectCredential(
for: protectionSpace,
candidates: candidates,
registerCancelPrompt: registerCancelPrompt,
completion: completion
)
},
registerCancelPrompt: registerCancelPrompt,
isCancelled: isCancelled,
completionHandler: finishPrompt
)
},
completionHandler: completionHandler
)
}
}
@@ -0,0 +1,154 @@
import AppKit
import CmuxBrowser
import Foundation
import WebKit
@MainActor struct BrowserClientCertificateCredentialPicker {
private let webView: WKWebView
private let presentAlert: BrowserAlertPresenter
private let textFormatter: BrowserAuthPromptTextFormatter
init(
webView: WKWebView,
presentAlert: @escaping BrowserAlertPresenter = browserPresentAlert,
textFormatter: BrowserAuthPromptTextFormatter = BrowserAuthPromptTextFormatter()
) {
self.webView = webView
self.presentAlert = presentAlert
self.textFormatter = textFormatter
}
func selectCredential(
for protectionSpace: URLProtectionSpace,
candidates: [BrowserClientCertificateCredentialCandidate],
registerCancelPrompt: ((@escaping () -> Void) -> Void)? = nil,
completion: @escaping (BrowserClientCertificateCredentialCandidate?) -> Void
) {
guard !candidates.isEmpty else {
completion(nil)
return
}
let alert = NSAlert()
alert.alertStyle = .informational
alert.messageText = String(
localized: "browser.dialog.clientCertificate.title",
defaultValue: "Choose a Certificate"
)
alert.informativeText = message(for: protectionSpace)
alert.addButton(withTitle: String(
localized: "browser.dialog.clientCertificate.continue",
defaultValue: "Continue"
))
alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel"))
let popup = NSPopUpButton(frame: NSRect(x: 0, y: 0, width: 360, height: 28), pullsDown: false)
popup.addItems(withTitles: candidates.enumerated().map { index, candidate in
title(for: candidate, at: index)
})
popup.selectItem(at: 0)
alert.accessoryView = popup
var didComplete = false
let finish: (BrowserClientCertificateCredentialCandidate?) -> Void = { selectedCandidate in
guard !didComplete else { return }
didComplete = true
completion(selectedCandidate)
}
let handleResponse: (NSApplication.ModalResponse) -> Void = { response in
guard response == .alertFirstButtonReturn else {
finish(nil)
return
}
let selectedIndex = popup.indexOfSelectedItem
guard candidates.indices.contains(selectedIndex) else {
finish(nil)
return
}
finish(candidates[selectedIndex])
}
let handleCancel = {
finish(nil)
}
registerCancelPrompt? {
Self.dismiss(alert)
handleCancel()
}
presentAlert(alert, webView, handleResponse) {
handleCancel()
}
}
private func message(for protectionSpace: URLProtectionSpace) -> String {
let format = String(
localized: "browser.dialog.clientCertificate.message",
defaultValue: "%@ requires a client certificate."
)
return String(format: format, locale: Locale.current, origin(for: protectionSpace))
}
private func origin(for protectionSpace: URLProtectionSpace) -> String {
textFormatter.origin(
protectionSpace: protectionSpace,
unknownHost: String(
localized: "browser.dialog.clientCertificate.unknownHost",
defaultValue: "This site"
)
)
}
private func title(
for candidate: BrowserClientCertificateCredentialCandidate,
at index: Int
) -> String {
let displayTitle: String
if let rawTitle = candidate.title,
case let title = textFormatter.middleElidedText(rawTitle),
!title.isEmpty {
displayTitle = title
} else {
let format = String(
localized: "browser.dialog.clientCertificate.fallbackCertificateName",
defaultValue: "Certificate %d"
)
displayTitle = String(format: format, locale: Locale.current, index + 1)
}
guard let subtitle = serialNumberSubtitle(for: candidate) else {
return displayTitle
}
let format = String(
localized: "browser.dialog.clientCertificate.titleWithSubtitle",
defaultValue: "%@ (%@)"
)
return String(format: format, locale: Locale.current, displayTitle, subtitle)
}
private func serialNumberSubtitle(for candidate: BrowserClientCertificateCredentialCandidate) -> String? {
guard let rawSerialNumber = candidate.serialNumber,
case let serialNumber = textFormatter.middleElidedText(rawSerialNumber),
!serialNumber.isEmpty else {
return nil
}
let format = String(
localized: "browser.dialog.clientCertificate.serialNumber",
defaultValue: "Serial %@"
)
return String(format: format, locale: Locale.current, serialNumber)
}
private static func dismiss(_ alert: NSAlert) {
let window = alert.window
if let sheetParent = window.sheetParent {
sheetParent.endSheet(window, returnCode: .alertSecondButtonReturn)
} else if window.isVisible {
NSApp.stopModal(withCode: .alertSecondButtonReturn)
window.close()
}
}
}
+13 -85
View File
@@ -2,95 +2,19 @@ import AppKit
import Foundation
import WebKit
private let browserHTTPBasicAuthPromptTextMaxLength = 240
private let browserHTTPBasicAuthPromptDangerousScalars: Set<Unicode.Scalar> = [
"\u{200B}", "\u{200C}", "\u{200D}", "\u{200E}", "\u{200F}",
"\u{202A}", "\u{202B}", "\u{202C}", "\u{202D}", "\u{202E}",
"\u{2066}", "\u{2067}", "\u{2068}", "\u{2069}",
"\u{FEFF}",
]
private func browserFilteredHTTPBasicAuthPromptText(_ text: String) -> String {
let filtered = String(text.unicodeScalars.filter { scalar in
!browserHTTPBasicAuthPromptDangerousScalars.contains(scalar)
&& !CharacterSet.controlCharacters.contains(scalar)
})
return filtered.trimmingCharacters(in: .whitespacesAndNewlines)
}
private func browserSanitizedHTTPBasicAuthPromptText(_ text: String) -> String {
let trimmed = browserFilteredHTTPBasicAuthPromptText(text)
guard trimmed.count > browserHTTPBasicAuthPromptTextMaxLength else {
return trimmed
}
return String(trimmed.prefix(browserHTTPBasicAuthPromptTextMaxLength))
}
private func browserMiddleElidedHTTPBasicAuthPromptText(_ text: String) -> String {
let trimmed = browserFilteredHTTPBasicAuthPromptText(text)
guard trimmed.count > browserHTTPBasicAuthPromptTextMaxLength else {
return trimmed
}
let marker = "..."
let keptCharacterCount = browserHTTPBasicAuthPromptTextMaxLength - marker.count
let prefixCount = min(48, max(16, keptCharacterCount / 3))
let suffixCount = max(0, keptCharacterCount - prefixCount)
return String(trimmed.prefix(prefixCount)) + marker + String(trimmed.suffix(suffixCount))
}
private func browserDefaultPort(forHTTPBasicAuthProtocol protocolName: String?) -> Int? {
switch protocolName?.lowercased() {
case "http":
return 80
case "https":
return 443
default:
return nil
}
}
private func browserHTTPBasicAuthPromptOrigin(
protectionSpace: URLProtectionSpace
private func browserHTTPBasicAuthPromptMessage(
challenge: URLAuthenticationChallenge,
textFormatter: BrowserAuthPromptTextFormatter
) -> String {
let host = browserFilteredHTTPBasicAuthPromptText(protectionSpace.host)
guard !host.isEmpty else {
return String(
let origin = textFormatter.origin(
protectionSpace: challenge.protectionSpace,
unknownHost: String(
localized: "browser.dialog.auth.basic.unknownHost",
defaultValue: "this site"
)
}
let rawProtocol = protectionSpace.`protocol` ?? ""
let protocolName = browserFilteredHTTPBasicAuthPromptText(rawProtocol).lowercased()
let defaultPort = browserDefaultPort(forHTTPBasicAuthProtocol: protocolName)
let displayHost: String
if host.contains(":") && !host.hasPrefix("[") && !host.hasSuffix("]") {
displayHost = "[\(host)]"
} else {
displayHost = host
}
let port = protectionSpace.port
let authority: String
if port > 0, port != defaultPort {
authority = "\(displayHost):\(port)"
} else {
authority = displayHost
}
let origin = protocolName.isEmpty ? authority : "\(protocolName)://\(authority)"
return browserMiddleElidedHTTPBasicAuthPromptText(origin)
}
private func browserHTTPBasicAuthPromptMessage(
challenge: URLAuthenticationChallenge
) -> String {
let origin = browserHTTPBasicAuthPromptOrigin(protectionSpace: challenge.protectionSpace)
)
if let rawRealm = challenge.protectionSpace.realm {
let realm = browserSanitizedHTTPBasicAuthPromptText(rawRealm)
let realm = textFormatter.sanitizedText(rawRealm)
guard !realm.isEmpty else {
let format = String(
localized: "browser.dialog.auth.basic.messageHost",
@@ -137,13 +61,17 @@ func browserHandleHTTPBasicAuthenticationChallenge(
}
let presentPrompt = {
let textFormatter = BrowserAuthPromptTextFormatter()
let alert = alertFactory()
alert.alertStyle = .informational
alert.messageText = String(
localized: "browser.dialog.auth.basic.title",
defaultValue: "Authentication Required"
)
let promptMessage = browserHTTPBasicAuthPromptMessage(challenge: challenge)
let promptMessage = browserHTTPBasicAuthPromptMessage(
challenge: challenge,
textFormatter: textFormatter
)
let accessoryMessage: String
if challenge.previousFailureCount > 0 {
let failureMessage = String(
+12 -11
View File
@@ -21,6 +21,7 @@ import WebKit
var lastAttemptedURL: URL?
private(set) var activeErrorPageDisplayURL: URL?
private let basicAuthPromptCoordinator = BrowserHTTPBasicAuthPromptCoordinator()
private let clientCertificateAuthenticationController = BrowserClientCertificateAuthenticationController()
private let sslBypassState = BrowserSSLTrustBypassState()
private var lastAttemptedRequest: URLRequest?
private var lastAttemptedRequestWasDiscardedForReplay = false
@@ -29,8 +30,9 @@ import WebKit
private var activeSSLTrustBypassReplayRequest: URLRequest?
private var activeSSLTrustBypassErrorPageRetryRequest: URLRequest?
func cancelPendingHTTPBasicAuthPrompts(allowFuturePrompts: Bool = false) {
func cancelPendingAuthenticationPrompts(allowFuturePrompts: Bool = false) {
basicAuthPromptCoordinator.cancelAll(allowFuturePrompts: allowFuturePrompts)
clientCertificateAuthenticationController.cancelAll(allowFuturePrompts: allowFuturePrompts)
}
func recordAttemptedRequest(_ request: URLRequest, displayURL: URL? = nil) {
@@ -161,16 +163,15 @@ import WebKit
return
}
// WKWebView rejects all authentication challenges by default when this
// delegate method is not implemented (.rejectProtectionSpace). This
// breaks TLS client-certificate flows such as Microsoft Entra ID
// Conditional Access, which verifies device compliance via a client
// certificate stored in the system keychain by MDM enrollment.
//
// By returning .performDefaultHandling the system's standard URL-loading
// behaviour takes over: the keychain is searched for matching client
// identities, MDM-installed root CAs are trusted, and any configured SSO
// extensions (e.g. Microsoft Enterprise SSO) can intercept the challenge.
if clientCertificateAuthenticationController.handle(
challenge: challenge,
in: webView,
presentAlert: presentAlert,
completionHandler: completionHandler
) {
return
}
completionHandler(.performDefaultHandling, nil)
}
+4 -4
View File
@@ -4252,8 +4252,8 @@ final class BrowserPanel: Panel, ObservableObject {
}
}
private func cancelPendingInteractiveBrowserPrompts(reason: String, cancelHTTPBasicAuthPrompts: Bool = true) {
if cancelHTTPBasicAuthPrompts { navigationDelegate?.cancelPendingHTTPBasicAuthPrompts(allowFuturePrompts: true) }
private func cancelPendingInteractiveBrowserPrompts(reason: String, cancelAuthenticationPrompts: Bool = true) {
if cancelAuthenticationPrompts { navigationDelegate?.cancelPendingAuthenticationPrompts(allowFuturePrompts: true) }
guard !pendingInteractiveBrowserPrompts.isEmpty else { return }
let prompts = pendingInteractiveBrowserPrompts
pendingInteractiveBrowserPrompts.removeAll()
@@ -5195,8 +5195,8 @@ final class BrowserPanel: Panel, ObservableObject {
GlobalSearchCoordinator.shared.purgePanel(id: id)
closeDeveloperToolsForTeardown()
unfocus()
navigationDelegate?.cancelPendingHTTPBasicAuthPrompts()
cancelPendingInteractiveBrowserPrompts(reason: "close", cancelHTTPBasicAuthPrompts: false)
navigationDelegate?.cancelPendingAuthenticationPrompts()
cancelPendingInteractiveBrowserPrompts(reason: "close", cancelAuthenticationPrompts: false)
closeBackgroundPreloadHost(reason: "close")
// Snapshot first: popup close unregisters itself from popupControllers.
@@ -326,7 +326,7 @@ final class BrowserPopupWindowController: NSObject, NSWindowDelegate {
WebViewInspectorTeardown.closeInspector(for: webView)
closeAllChildPopups()
popupNavigationDelegate.cancelPendingHTTPBasicAuthPrompts()
popupNavigationDelegate.cancelPendingAuthenticationPrompts()
// Invalidate observations
titleObservation?.invalidate()
@@ -622,6 +622,7 @@ private class PopupUIDelegate: NSObject, WKUIDelegate {
weak var controller: BrowserPopupWindowController?
var downloadDelegate: WKDownloadDelegate?
private let basicAuthPromptCoordinator = BrowserHTTPBasicAuthPromptCoordinator()
private let clientCertificateAuthenticationController = BrowserClientCertificateAuthenticationController()
private let sslBypassState = BrowserSSLTrustBypassState()
private var lastAttemptedURL: URL?
private var lastAttemptedRequest: URLRequest?
@@ -632,8 +633,9 @@ private class PopupUIDelegate: NSObject, WKUIDelegate {
private(set) var activeErrorPageDisplayURL: URL?
private var activeSSLTrustBypassErrorPageRetryRequest: URLRequest?
func cancelPendingHTTPBasicAuthPrompts() {
func cancelPendingAuthenticationPrompts() {
basicAuthPromptCoordinator.cancelAll()
clientCertificateAuthenticationController.cancelAll()
}
private func recordAttemptedRequest(_ request: URLRequest) {
@@ -903,17 +905,15 @@ private class PopupUIDelegate: NSObject, WKUIDelegate {
startPrompt: { finishPrompt, registerCancelPrompt in
browserHandleHTTPBasicAuthenticationChallenge(
in: webView, challenge: challenge,
registerCancelPrompt: registerCancelPrompt,
completionHandler: finishPrompt
registerCancelPrompt: registerCancelPrompt, completionHandler: finishPrompt
)
},
completionHandler: completionHandler
) {
return
}
) { return }
if clientCertificateAuthenticationController.handle(
challenge: challenge, in: webView, completionHandler: completionHandler
) { return }
// Parity with main browser: performDefaultHandling enables system keychain
// lookups, MDM client certs, and SSO extensions (e.g. Microsoft Entra ID).
completionHandler(.performDefaultHandling, nil)
}
+4
View File
@@ -10,6 +10,10 @@
<true/>
<key>com.apple.developer.web-browser.public-key-credential</key>
<true/>
<key>keychain-access-groups</key>
<array>
<string>7WLXT3NR37.com.cmuxterm.app</string>
</array>
<key>com.apple.security.device.camera</key>
<true/>
<key>com.apple.security.device.audio-input</key>
+4
View File
@@ -6,6 +6,10 @@
<string>7WLXT3NR37.com.cmuxterm.app.nightly</string>
<key>com.apple.developer.team-identifier</key>
<string>7WLXT3NR37</string>
<key>keychain-access-groups</key>
<array>
<string>7WLXT3NR37.com.cmuxterm.app.nightly</string>
</array>
<key>com.apple.developer.web-browser.public-key-credential</key>
<true/>
<key>com.apple.security.automation.apple-events</key>
+4
View File
@@ -6,6 +6,10 @@
<string>7WLXT3NR37.com.cmuxterm.app</string>
<key>com.apple.developer.team-identifier</key>
<string>7WLXT3NR37</string>
<key>keychain-access-groups</key>
<array>
<string>7WLXT3NR37.com.cmuxterm.app</string>
</array>
<key>com.apple.developer.web-browser.public-key-credential</key>
<true/>
<key>com.apple.security.automation.apple-events</key>
+24
View File
@@ -103,9 +103,13 @@
D0B10002A1B2C3D4E5F60001 /* BonsplitTabBarPassThrough.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B10003A1B2C3D4E5F60001 /* BonsplitTabBarPassThrough.swift */; };
AA1B2C3D4E5F60718 /* BonsplitTabDragUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1B2C3D4E5F60719 /* BonsplitTabDragUITests.swift */; };
D3622000A1B2C3D4E5F60718 /* BrowserArrowKeyForwardingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3622001A1B2C3D4E5F60718 /* BrowserArrowKeyForwardingTests.swift */; };
D7032A070000000000000001 /* BrowserAuthPromptTextFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7032A070000000000000002 /* BrowserAuthPromptTextFormatter.swift */; };
B3770BA00000000000000001 /* BrowserAutomation.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3770BA00000000000000002 /* BrowserAutomation.swift */; };
BCBC0A0E0000000000000C01 /* BrowserChromeMetrics.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCBC0A0E0000000000000C02 /* BrowserChromeMetrics.swift */; };
BCBC0A0E0000000000000D01 /* BrowserChromeMetricsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCBC0A0E0000000000000D02 /* BrowserChromeMetricsTests.swift */; };
D7032A020000000000000001 /* BrowserClientCertificateAuthenticationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7032A020000000000000002 /* BrowserClientCertificateAuthenticationController.swift */; };
D7032A050000000000000001 /* BrowserClientCertificateCredentialPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7032A050000000000000002 /* BrowserClientCertificateCredentialPicker.swift */; };
D7032A030000000000000001 /* BrowserClientCertificateCredentialPickerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7032A030000000000000002 /* BrowserClientCertificateCredentialPickerTests.swift */; };
E12E88F82733EC42F32C36A3 /* BrowserConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 970226F3C99D0D937CD00539 /* BrowserConfigTests.swift */; };
C59240010000000000000001 /* BrowserDownloadFilenameResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = C59240010000000000000002 /* BrowserDownloadFilenameResolver.swift */; };
C59240010000000000000003 /* BrowserDownloadFilenameResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C59240010000000000000004 /* BrowserDownloadFilenameResolverTests.swift */; };
@@ -252,6 +256,7 @@
C9A2B00000000000000000C3 /* CmuxAppKitSupportUI in Frameworks */ = {isa = PBXBuildFile; productRef = C9A2B00000000000000000C2 /* CmuxAppKitSupportUI */; };
5EDB6027B346C46521A93C74 /* CMUXAuthCore in Frameworks */ = {isa = PBXBuildFile; productRef = 29813FE5A6CBC1019289A251 /* CMUXAuthCore */; };
53750003A0B1C2D3E4F50003 /* CmuxAuthRuntime in Frameworks */ = {isa = PBXBuildFile; productRef = 53750002A0B1C2D3E4F50002 /* CmuxAuthRuntime */; };
D7032A060000000000000001 /* CmuxBrowser in Frameworks */ = {isa = PBXBuildFile; productRef = D7032A060000000000000002 /* CmuxBrowser */; };
E3B7A30000000000000000C3 /* CmuxBrowser in Frameworks */ = {isa = PBXBuildFile; productRef = E3B7A30000000000000000C2 /* CmuxBrowser */; };
CA52A003CA52A003CA52A003 /* CmuxCanvas in Frameworks */ = {isa = PBXBuildFile; productRef = CA52A005CA52A005CA52A005 /* CmuxCanvas */; };
CA52A007CA52A007CA52A007 /* CmuxCanvas in Frameworks */ = {isa = PBXBuildFile; productRef = CA52A005CA52A005CA52A005 /* CmuxCanvas */; };
@@ -1362,9 +1367,13 @@
D0B10003A1B2C3D4E5F60001 /* BonsplitTabBarPassThrough.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BonsplitTabBarPassThrough.swift; sourceTree = "<group>"; };
AA1B2C3D4E5F60719 /* BonsplitTabDragUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BonsplitTabDragUITests.swift; sourceTree = "<group>"; };
D3622001A1B2C3D4E5F60718 /* BrowserArrowKeyForwardingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserArrowKeyForwardingTests.swift; sourceTree = "<group>"; };
D7032A070000000000000002 /* BrowserAuthPromptTextFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserAuthPromptTextFormatter.swift; sourceTree = "<group>"; };
B3770BA00000000000000002 /* BrowserAutomation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserAutomation.swift; sourceTree = "<group>"; };
BCBC0A0E0000000000000C02 /* BrowserChromeMetrics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserChromeMetrics.swift; sourceTree = "<group>"; };
BCBC0A0E0000000000000D02 /* BrowserChromeMetricsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserChromeMetricsTests.swift; sourceTree = "<group>"; };
D7032A020000000000000002 /* BrowserClientCertificateAuthenticationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserClientCertificateAuthenticationController.swift; sourceTree = "<group>"; };
D7032A050000000000000002 /* BrowserClientCertificateCredentialPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserClientCertificateCredentialPicker.swift; sourceTree = "<group>"; };
D7032A030000000000000002 /* BrowserClientCertificateCredentialPickerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserClientCertificateCredentialPickerTests.swift; sourceTree = "<group>"; };
970226F3C99D0D937CD00539 /* BrowserConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserConfigTests.swift; sourceTree = "<group>"; };
C59240010000000000000002 /* BrowserDownloadFilenameResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserDownloadFilenameResolver.swift; sourceTree = "<group>"; };
C59240010000000000000004 /* BrowserDownloadFilenameResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserDownloadFilenameResolverTests.swift; sourceTree = "<group>"; };
@@ -2477,6 +2486,7 @@
files = (
B057B0017E57B0017E57B001 /* Bonsplit in Frameworks */,
C9A2B00000000000000000C3 /* CmuxAppKitSupportUI in Frameworks */,
D7032A060000000000000001 /* CmuxBrowser in Frameworks */,
CA52A007CA52A007CA52A007 /* CmuxCanvas in Frameworks */,
5E55500000000000000000E4 /* CmuxCommandPalette in Frameworks */,
CC0DE00000000000000000A4 /* CmuxCore in Frameworks */,
@@ -3040,6 +3050,9 @@
A5001411 /* TerminalPanel.swift */,
B42450020000000000000001 /* BrowserHiddenWebViewDiscardPolicy.swift */,
B42450040000000000000001 /* BrowserHiddenWebViewDiscardManager.swift */,
D7032A070000000000000002 /* BrowserAuthPromptTextFormatter.swift */,
D7032A020000000000000002 /* BrowserClientCertificateAuthenticationController.swift */,
D7032A050000000000000002 /* BrowserClientCertificateCredentialPicker.swift */,
BABA25000000000000000002 /* BrowserHTTPBasicAuthPromptCoordinator.swift */,
BABA25000000000000000008 /* BrowserHTTPBasicAuthPrompt.swift */,
BABA2500000000000000000C /* BrowserHTTPBasicAuthProtectionSpaceKey.swift */,
@@ -3494,6 +3507,7 @@
D36090010000000000000006 /* CmuxMainWindowConstrainFrameTests.swift */,
D36090020000000000000006 /* CmuxMainWindowFullScreenCapabilityTests.swift */,
970226F3C99D0D937CD00539 /* BrowserConfigTests.swift */,
D7032A030000000000000002 /* BrowserClientCertificateCredentialPickerTests.swift */,
BABA25000000000000000006 /* BrowserHTTPBasicAuthPromptCoordinatorTests.swift */,
BABA25000000000000000004 /* BrowserHTTPBasicAuthPromptTests.swift */,
8D828DA0070335773EBAE83F /* BrowserSystemProxyMirrorTests.swift */,
@@ -3874,6 +3888,7 @@
5E27010400000000000000A3 /* CmuxSettings */,
5E27010400000000000000A4 /* CmuxWorkspaces */,
C9A2B00000000000000000C2 /* CmuxAppKitSupportUI */,
D7032A060000000000000002 /* CmuxBrowser */,
5E55500000000000000000E2 /* CmuxCommandPalette */,
C0DE4A000000000000000004 /* CmuxSidebarProviderKit */,
CC0DE00000000000000000A2 /* CmuxCore */,
@@ -4197,8 +4212,11 @@
A50012F1 /* Backport.swift in Sources */,
D0B10010A1B2C3D4E5F60001 /* BonsplitTabBarDebug.swift in Sources */,
D0B10002A1B2C3D4E5F60001 /* BonsplitTabBarPassThrough.swift in Sources */,
D7032A070000000000000001 /* BrowserAuthPromptTextFormatter.swift in Sources */,
B3770BA00000000000000001 /* BrowserAutomation.swift in Sources */,
BCBC0A0E0000000000000C01 /* BrowserChromeMetrics.swift in Sources */,
D7032A020000000000000001 /* BrowserClientCertificateAuthenticationController.swift in Sources */,
D7032A050000000000000001 /* BrowserClientCertificateCredentialPicker.swift in Sources */,
C59240010000000000000001 /* BrowserDownloadFilenameResolver.swift in Sources */,
C2035A010000000000000001 /* BrowserErrorPage.swift in Sources */,
C2035A020000000000000001 /* BrowserErrorPageContent.swift in Sources */,
@@ -4983,6 +5001,7 @@
74AB3F34FE3B4974A3A5D264 /* AutoNamingHookPayloadAdapterTests.swift in Sources */,
D3622000A1B2C3D4E5F60718 /* BrowserArrowKeyForwardingTests.swift in Sources */,
BCBC0A0E0000000000000D01 /* BrowserChromeMetricsTests.swift in Sources */,
D7032A030000000000000001 /* BrowserClientCertificateCredentialPickerTests.swift in Sources */,
E12E88F82733EC42F32C36A3 /* BrowserConfigTests.swift in Sources */,
C59240010000000000000003 /* BrowserDownloadFilenameResolverTests.swift in Sources */,
A5008381 /* BrowserFindJavaScriptTests.swift in Sources */,
@@ -5963,6 +5982,11 @@
package = E3B7A30000000000000000C1 /* XCLocalSwiftPackageReference "CmuxBrowser" */;
productName = CmuxBrowser;
};
D7032A060000000000000002 /* CmuxBrowser */ = {
isa = XCSwiftPackageProductDependency;
package = E3B7A30000000000000000C1 /* XCLocalSwiftPackageReference "CmuxBrowser" */;
productName = CmuxBrowser;
};
E3B7A30000000000000000D2 /* CmuxNotifications */ = {
isa = XCSwiftPackageProductDependency;
package = E3B7A30000000000000000D1 /* XCLocalSwiftPackageReference "CmuxNotifications" */;
@@ -0,0 +1,64 @@
import AppKit
import CmuxBrowser
import Foundation
import Testing
import WebKit
#if canImport(cmux_DEV)
@testable import cmux_DEV
#elseif canImport(cmux)
@testable import cmux
#endif
@MainActor @Suite
struct BrowserClientCertificateCredentialPickerTests {
private func makeProtectionSpace(
host: String,
port: Int = 443,
protocolName: String = "https"
) -> URLProtectionSpace {
URLProtectionSpace(
host: host,
port: port,
protocol: protocolName,
realm: nil,
authenticationMethod: NSURLAuthenticationMethodClientCertificate
)
}
@Test
func pickerSanitizesCredentialReleaseOrigin() {
let webView = WKWebView(frame: .zero)
let candidate = BrowserClientCertificateCredentialCandidate(
title: "Client\u{202E}\n",
serialNumber: "\u{202E}\n123",
credential: URLCredential(user: "client-cert", password: "unused", persistence: .forSession)
)
let picker = BrowserClientCertificateCredentialPicker(
webView: webView,
presentAlert: { alert, presentedWebView, completion, _ in
#expect(presentedWebView === webView)
#expect(alert.informativeText.contains("https://mtls.example:8443"))
#expect(alert.informativeText.contains("\u{202E}") == false)
#expect(alert.informativeText.contains("\n") == false)
let popup = alert.accessoryView as? NSPopUpButton
let popupTitle = popup?.itemTitles.first ?? ""
#expect(popupTitle.contains("Client"))
#expect(popupTitle.contains("Serial 123"))
#expect(popupTitle.contains("\u{202E}") == false)
#expect(popupTitle.contains("\n") == false)
completion(.alertSecondButtonReturn)
}
)
var selectedCandidate: BrowserClientCertificateCredentialCandidate?
picker.selectCredential(
for: makeProtectionSpace(host: "mtls\u{202E}.example\n", port: 8443),
candidates: [candidate]
) { selection in
selectedCandidate = selection
}
#expect(selectedCandidate == nil)
}
}
+1 -9
View File
@@ -96,15 +96,7 @@ echo "Sparkle keys injected"
# --- Codesign ---
echo "Codesigning..."
CLI_PATH="$APP_PATH/Contents/Resources/bin/cmux"
if [ -f "$CLI_PATH" ]; then
/usr/bin/codesign --force --options runtime --timestamp --sign "$SIGN_HASH" --entitlements "$ENTITLEMENTS" "$CLI_PATH"
fi
if [ -f "$HELPER_PATH" ]; then
/usr/bin/codesign --force --options runtime --timestamp --sign "$SIGN_HASH" --entitlements "$ENTITLEMENTS" "$HELPER_PATH"
fi
/usr/bin/codesign --force --options runtime --timestamp --sign "$SIGN_HASH" --entitlements "$ENTITLEMENTS" --deep "$APP_PATH"
/usr/bin/codesign --verify --deep --strict --verbose=2 "$APP_PATH"
./scripts/sign-cmux-bundle.sh "$APP_PATH" "$ENTITLEMENTS" "$SIGN_HASH"
echo "Codesign verified"
# --- Notarize app ---