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:
+112
@@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+34
@@ -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
|
||||
}
|
||||
}
|
||||
+243
@@ -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)
|
||||
}
|
||||
}
|
||||
+153
@@ -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())
|
||||
}
|
||||
}
|
||||
+60
@@ -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) }
|
||||
}
|
||||
}
|
||||
+33
@@ -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
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
extension URLAuthenticationChallenge {
|
||||
var isBrowserClientCertificateChallenge: Bool {
|
||||
protectionSpace.authenticationMethod == NSURLAuthenticationMethodClientCertificate
|
||||
}
|
||||
}
|
||||
+9
@@ -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) {}
|
||||
}
|
||||
+417
@@ -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([]))
|
||||
}
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 ---
|
||||
|
||||
Reference in New Issue
Block a user