Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions .github/workflows/nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -393,20 +393,22 @@ jobs:
echo "Missing APPLE_SIGNING_IDENTITY secret" >&2
exit 1
fi
ENTITLEMENTS="cmux.entitlements"
APP_ENTITLEMENTS="cmux.entitlements"
EMBEDDED_ENTITLEMENTS="cmux.embedded.entitlements"
for APP_PATH in \
"build-universal/Build/Products/Release/cmux NIGHTLY.app"
do
CLI_PATH="$APP_PATH/Contents/Resources/bin/cmux"
HELPER_PATH="$APP_PATH/Contents/Resources/bin/ghostty"
if [ -f "$CLI_PATH" ]; then
/usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS" "$CLI_PATH"
/usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$EMBEDDED_ENTITLEMENTS" "$CLI_PATH"
fi
if [ -f "$HELPER_PATH" ]; then
/usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS" "$HELPER_PATH"
/usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$EMBEDDED_ENTITLEMENTS" "$HELPER_PATH"
fi
/usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS" --deep "$APP_PATH"
/usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$APP_ENTITLEMENTS" "$APP_PATH"
/usr/bin/codesign --verify --deep --strict --verbose=2 "$APP_PATH"
./scripts/assert-passkey-entitlement.sh "$APP_PATH"
done

- name: Notarize apps and dmgs
Expand Down
10 changes: 6 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -257,17 +257,19 @@ jobs:
exit 1
fi
APP_PATH="build-universal/Build/Products/Release/cmux.app"
ENTITLEMENTS="cmux.entitlements"
APP_ENTITLEMENTS="cmux.entitlements"
EMBEDDED_ENTITLEMENTS="cmux.embedded.entitlements"
CLI_PATH="$APP_PATH/Contents/Resources/bin/cmux"
HELPER_PATH="$APP_PATH/Contents/Resources/bin/ghostty"
if [ -f "$CLI_PATH" ]; then
/usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS" "$CLI_PATH"
/usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$EMBEDDED_ENTITLEMENTS" "$CLI_PATH"
fi
if [ -f "$HELPER_PATH" ]; then
/usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS" "$HELPER_PATH"
/usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$EMBEDDED_ENTITLEMENTS" "$HELPER_PATH"
fi
/usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS" --deep "$APP_PATH"
/usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$APP_ENTITLEMENTS" "$APP_PATH"
/usr/bin/codesign --verify --deep --strict --verbose=2 "$APP_PATH"
./scripts/assert-passkey-entitlement.sh "$APP_PATH"

- name: Notarize app
if: steps.guard_release_assets.outputs.skip_all != 'true'
Expand Down
2 changes: 2 additions & 0 deletions Resources/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@
<string>A program running within cmux would like to use your microphone.</string>
<key>NSCameraUsageDescription</key>
<string>A program running within cmux would like to use your camera.</string>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>cmux uses Bluetooth for cross-device passkey sign-in in embedded browser authentication flows.</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
Expand Down
113 changes: 113 additions & 0 deletions Resources/InfoPlist.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,119 @@
"sourceLanguage": "en",
"version": "1.0",
"strings": {
"NSBluetoothAlwaysUsageDescription": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "cmux uses Bluetooth for cross-device passkey sign-in in embedded browser authentication flows."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "cmux は内蔵ブラウザの認証フローでクロスデバイスのパスキーサインインに Bluetooth を使用します。"
}
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "cmux 在嵌入式浏览器身份验证流程中使用蓝牙进行跨设备通行密钥登录。"
}
},
"zh-Hant": {
"stringUnit": {
"state": "translated",
"value": "cmux 在內嵌瀏覽器身份驗證流程中使用藍牙進行跨裝置密碼金鑰登入。"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "cmux는 내장 브라우저 인증 흐름에서 기기 간 패스키 로그인에 Bluetooth를 사용합니다."
}
},
"de": {
"stringUnit": {
"state": "translated",
"value": "cmux verwendet Bluetooth für die geräteübergreifende Passkey-Anmeldung in den Authentifizierungsabläufen des integrierten Browsers."
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "cmux usa Bluetooth para el inicio de sesión con claves de acceso entre dispositivos en los flujos de autenticación del navegador integrado."
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "cmux utilise Bluetooth pour la connexion par clé d'accès entre appareils dans les flux d'authentification du navigateur intégré."
}
},
"it": {
"stringUnit": {
"state": "translated",
"value": "cmux usa il Bluetooth per l'accesso con passkey tra dispositivi nei flussi di autenticazione del browser integrato."
}
},
"da": {
"stringUnit": {
"state": "translated",
"value": "cmux bruger Bluetooth til adgangsnøgle-login på tværs af enheder i den integrerede browsers godkendelsesforløb."
}
},
"pl": {
"stringUnit": {
"state": "translated",
"value": "cmux używa Bluetooth do logowania za pomocą kluczy dostępu między urządzeniami w procesach uwierzytelniania wbudowanej przeglądarki."
}
},
"ru": {
"stringUnit": {
"state": "translated",
"value": "cmux использует Bluetooth для входа с помощью ключей доступа между устройствами в потоках аутентификации встроенного браузера."
}
},
"bs": {
"stringUnit": {
"state": "translated",
"value": "cmux koristi Bluetooth za prijavu pomoću pristupnog ključa između uređaja u tokovima autentifikacije ugrađenog preglednika."
}
},
"ar": {
"stringUnit": {
"state": "translated",
"value": "يستخدم cmux البلوتوث لتسجيل الدخول باستخدام مفاتيح المرور عبر الأجهزة في تدفقات المصادقة بالمتصفح المدمج."
}
},
"nb": {
"stringUnit": {
"state": "translated",
"value": "cmux bruker Bluetooth for tilgangsnøkkel-pålogging på tvers av enheter i den innebygde nettleserens autentiseringsflyter."
}
},
"pt-BR": {
"stringUnit": {
"state": "translated",
"value": "O cmux usa Bluetooth para login com chaves de acesso entre dispositivos nos fluxos de autenticação do navegador integrado."
}
},
"th": {
"stringUnit": {
"state": "translated",
"value": "cmux ใช้บลูทูธสำหรับการลงชื่อเข้าใช้ด้วย passkey ข้ามอุปกรณ์ในขั้นตอนการตรวจสอบสิทธิ์ของเบราว์เซอร์ในตัว"
}
},
"tr": {
"stringUnit": {
"state": "translated",
"value": "cmux, gömülü tarayıcının kimlik doğrulama akışlarında cihazlar arası geçiş anahtarı oturum açma için Bluetooth kullanır."
}
}
}
Comment on lines +5 to +116
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 NSBluetoothAlwaysUsageDescription missing locale translations

This new key only includes en and ja translations, but every other key in this file (NSMicrophoneUsageDescription, New $(PRODUCT_NAME) Workspace Here, etc.) carries 16 locales (zh-Hans, zh-Hant, ko, de, es, fr, it, da, pl, ru, bs, ar, nb, pt-BR, th, tr). Users on those locales will see the English fallback in the macOS Bluetooth permission dialog when a cross-device passkey flow is triggered.

},
"NSCameraUsageDescription": {
"extractionState": "manual",
"localizations": {
Expand Down
166 changes: 166 additions & 0 deletions Sources/Panels/BrowserPanel.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Foundation
import Combine
import AuthenticationServices
import WebKit
import AppKit
import Bonsplit
Expand Down Expand Up @@ -1728,6 +1729,164 @@ final class BrowserPortalAnchorView: NSView {
}
}

// WKWebView handles WebAuthn itself for browser apps; this support code only
// determines when cmux should request browser passkey access from macOS.
enum BrowserPasskeyAuthorizationSupport {
static let remoteLoopbackAliasHost = "cmux-loopback.localtest.me"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate loopback alias host constant risks future drift

Low Severity

The magic string "cmux-loopback.localtest.me" is now defined as BrowserPasskeyAuthorizationSupport.remoteLoopbackAliasHost in addition to the existing BrowserPanel.remoteLoopbackProxyAliasHost (and a third copy in Workspace). If one copy is updated without the others, the passkey trustworthiness check and the proxy rewriting logic would silently disagree on which hosts are treated as loopback, creating a security-relevant inconsistency.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 6458077. Configure here.


static func isPotentiallyTrustworthy(url: URL?) -> Bool {
guard let url,
let scheme = url.scheme?.lowercased() else {
return false
}
if scheme == "https" {
return true
}
guard scheme == "http",
let host = url.host else {
return false
}
return isLoopbackHost(host)
}

static func shouldRequestAuthorization(
for url: URL?,
authorizationState: ASAuthorizationWebBrowserPublicKeyCredentialManager.AuthorizationState,
hasPendingRequest: Bool,
didPromptThisSession: Bool
) -> Bool {
guard isPotentiallyTrustworthy(url: url) else { return false }
guard authorizationState == .notDetermined else { return false }
guard !hasPendingRequest else { return false }
guard !didPromptThisSession else { return false }
return true
}

/// Returns a host/origin string suitable for debug logs without leaking
/// query parameters, paths, OIDC state, or auth codes.
static func redactedURLDescription(for url: URL?) -> String {
guard let url else { return "nil" }
let scheme = url.scheme?.lowercased() ?? "?"
let host = url.host ?? "?"
return "\(scheme)://\(host)"
}

private static func isLoopbackHost(_ host: String) -> Bool {
let normalized = normalizedHost(host)
let octets = normalized.split(separator: ".", omittingEmptySubsequences: false)
let is127Loopback = octets.count == 4
&& octets[0] == "127"
&& octets.allSatisfy { component in
guard let value = Int(component) else { return false }
return (0...255).contains(value)
}

return normalized == "localhost"
|| normalized.hasSuffix(".localhost")
|| normalized == "::1"
|| is127Loopback
|| normalized == remoteLoopbackAliasHost
|| normalized.hasSuffix("." + remoteLoopbackAliasHost)
}

private static func normalizedHost(_ host: String) -> String {
var normalized = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if normalized.hasPrefix("[") && normalized.hasSuffix("]") {
normalized.removeFirst()
normalized.removeLast()
}
if normalized.hasSuffix(".") {
normalized.removeLast()
}
return normalized
}
}

private extension ASAuthorizationWebBrowserPublicKeyCredentialManager.AuthorizationState {
var cmuxDebugName: String {
switch self {
case .authorized:
return "authorized"
case .denied:
return "denied"
case .notDetermined:
return "notDetermined"
@unknown default:
return "unknown"
}
}
}

@MainActor
final class BrowserPasskeyAuthorizationManager {
static let shared = BrowserPasskeyAuthorizationManager()

private let credentialManager = ASAuthorizationWebBrowserPublicKeyCredentialManager()
private var authorizationTask: Task<ASAuthorizationWebBrowserPublicKeyCredentialManager.AuthorizationState, Never>?
private var didPromptThisSession = false

private init() {}

func requestAuthorizationIfNeeded(for url: URL?, source: String) {
// Skip the system passkey authorization prompt when running under
// automated UI tests; the dialog blocks the main thread and breaks
// unrelated keyboard regressions on CI.
if SessionRestorePolicy.isRunningUnderAutomatedTests() {
return
}

let state = credentialManager.authorizationStateForPlatformCredentials
let redactedURL = BrowserPasskeyAuthorizationSupport.redactedURLDescription(for: url)
guard BrowserPasskeyAuthorizationSupport.shouldRequestAuthorization(
for: url,
authorizationState: state,
hasPendingRequest: authorizationTask != nil,
didPromptThisSession: didPromptThisSession
) else {
#if DEBUG
dlog(
"browser.passkey.authorization.skip " +
"source=\(source) origin=\(redactedURL) state=\(state.cmuxDebugName) " +
"pending=\(authorizationTask == nil ? 0 : 1) prompted=\(didPromptThisSession ? 1 : 0)"
)
#endif
return
}

didPromptThisSession = true
#if DEBUG
dlog(
"browser.passkey.authorization.begin " +
"source=\(source) origin=\(redactedURL) state=\(state.cmuxDebugName)"
)
#endif

let task = Task<ASAuthorizationWebBrowserPublicKeyCredentialManager.AuthorizationState, Never> { [credentialManager] in
await withCheckedContinuation { continuation in
credentialManager.requestAuthorizationForPublicKeyCredentials { authorizationState in
continuation.resume(returning: authorizationState)
}
}
}
authorizationTask = task

Task { @MainActor [weak self] in
guard let self else { return }
let result = await task.value
self.authorizationTask = nil
if result == .notDetermined {
self.didPromptThisSession = false
}
#if DEBUG
dlog(
"browser.passkey.authorization.end " +
"source=\(source) origin=\(redactedURL) result=\(result.cmuxDebugName)"
)
#endif
}
}
}

@MainActor
final class BrowserPanel: Panel, ObservableObject {
private static let remoteLoopbackProxyAliasHost = "cmux-loopback.localtest.me"
Expand Down Expand Up @@ -6189,6 +6348,13 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate {
return
}

if navigationAction.targetFrame?.isMainFrame != false {
BrowserPasskeyAuthorizationManager.shared.requestAuthorizationIfNeeded(
for: navigationAction.request.url,
source: "browser.navigation.mainFrame"
)
}

#if DEBUG
let targetURL = navigationAction.request.url?.absoluteString ?? "nil"
dlog("browser.nav.decidePolicy.action kind=allow url=\(targetURL)")
Expand Down
5 changes: 5 additions & 0 deletions Sources/Panels/BrowserPopupWindowController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -577,6 +577,11 @@ private class PopupNavigationDelegate: NSObject, WKNavigationDelegate {
return
}

BrowserPasskeyAuthorizationManager.shared.requestAuthorizationIfNeeded(
for: url,
source: "browser.popup.navigation.mainFrame"
)

decisionHandler(.allow)
}

Expand Down
18 changes: 18 additions & 0 deletions cmux.embedded.entitlements
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.device.camera</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.automation.apple-events</key>
<true/>
</dict>
</plist>
Loading
Loading