From 2714d07c9f4dea358131dd8c7fa74709c4ea3863 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 25 Feb 2026 00:30:39 -0800 Subject: [PATCH] Scope socket password keychain per debug run and harden CLI lookup --- CLI/cmux.swift | 95 +++++++++--- Sources/SocketControlSettings.swift | 102 +++++++++++-- tests/test_cli_version_flag.py | 18 ++- tests/test_socket_password_keychain_scope.py | 146 +++++++++++++++++++ 4 files changed, 324 insertions(+), 37 deletions(-) create mode 100644 tests/test_socket_password_keychain_scope.py diff --git a/CLI/cmux.swift b/CLI/cmux.swift index da780371..07cafe21 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -1,5 +1,6 @@ import Foundation import Darwin +import LocalAuthentication import Security #if canImport(Sentry) import Sentry @@ -419,14 +420,14 @@ private enum SocketPasswordResolver { private static let service = "com.cmuxterm.app.socket-control" private static let account = "local-socket-password" - static func resolve(explicit: String?) -> String? { + static func resolve(explicit: String?, socketPath: String) -> String? { if let explicit = normalized(explicit), !explicit.isEmpty { return explicit } if let env = normalized(ProcessInfo.processInfo.environment["CMUX_SOCKET_PASSWORD"]), !env.isEmpty { return env } - return loadFromKeychain() + return loadFromKeychain(socketPath: socketPath) } private static func normalized(_ value: String?) -> String? { @@ -435,23 +436,81 @@ private enum SocketPasswordResolver { return trimmed.isEmpty ? nil : trimmed } - private static func loadFromKeychain() -> String? { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: account, - kSecReturnData as String: true, - kSecMatchLimit as String: kSecMatchLimitOne, - ] - var result: CFTypeRef? - let status = SecItemCopyMatching(query as CFDictionary, &result) - guard status == errSecSuccess else { - return nil + private static func keychainServices(socketPath: String) -> [String] { + guard let scope = keychainScope(socketPath: socketPath) else { + return [service] } - guard let data = result as? Data else { - return nil + return ["\(service).\(scope)"] + } + + private static func keychainScope(socketPath: String) -> String? { + if let tag = normalized(ProcessInfo.processInfo.environment["CMUX_TAG"]) { + let scoped = sanitizeScope(tag) + if !scoped.isEmpty { + return scoped + } } - return String(data: data, encoding: .utf8) + + let candidate = URL(fileURLWithPath: socketPath).lastPathComponent + let prefixes = ["cmux-debug-", "cmux-"] + for prefix in prefixes { + guard candidate.hasPrefix(prefix), candidate.hasSuffix(".sock") else { continue } + let start = candidate.index(candidate.startIndex, offsetBy: prefix.count) + let end = candidate.index(candidate.endIndex, offsetBy: -".sock".count) + guard start < end else { continue } + let rawScope = String(candidate[start.. String { + let lowered = raw.lowercased() + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: ".-")) + let mappedScalars = lowered.unicodeScalars.map { scalar -> Character in + allowed.contains(scalar) ? Character(scalar) : "." + } + var normalizedScope = String(mappedScalars) + normalizedScope = normalizedScope.replacingOccurrences( + of: "\\.+", + with: ".", + options: .regularExpression + ) + normalizedScope = normalizedScope.trimmingCharacters(in: CharacterSet(charactersIn: ".")) + return normalizedScope + } + + private static func loadFromKeychain(socketPath: String) -> String? { + for service in keychainServices(socketPath: socketPath) { + let authContext = LAContext() + authContext.interactionNotAllowed = true + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + // Never trigger keychain UI from CLI commands; fail fast instead. + kSecUseAuthenticationContext as String: authContext, + ] + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound || status == errSecInteractionNotAllowed || status == errSecAuthFailed { + continue + } + guard status == errSecSuccess else { + continue + } + guard let data = result as? Data, + let password = String(data: data, encoding: .utf8) else { + continue + } + return password + } + return nil } } @@ -769,7 +828,7 @@ struct CMUXCLI { } defer { client.close() } - if let socketPassword = SocketPasswordResolver.resolve(explicit: socketPasswordArg) { + if let socketPassword = SocketPasswordResolver.resolve(explicit: socketPasswordArg, socketPath: socketPath) { let authResponse = try client.send(command: "auth \(socketPassword)") if authResponse.hasPrefix("ERROR:"), !authResponse.contains("Unknown command 'auth'") { diff --git a/Sources/SocketControlSettings.swift b/Sources/SocketControlSettings.swift index b9705095..376a7a92 100644 --- a/Sources/SocketControlSettings.swift +++ b/Sources/SocketControlSettings.swift @@ -61,10 +61,80 @@ enum SocketControlPasswordStore { static let service = "com.cmuxterm.app.socket-control" static let account = "local-socket-password" - private static var baseQuery: [String: Any] { + private static func normalized(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private static func sanitizeScope(_ raw: String) -> String { + let lowered = raw.lowercased() + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: ".-")) + let mappedScalars = lowered.unicodeScalars.map { scalar -> Character in + allowed.contains(scalar) ? Character(scalar) : "." + } + var normalizedScope = String(mappedScalars) + normalizedScope = normalizedScope.replacingOccurrences( + of: "\\.+", + with: ".", + options: .regularExpression + ) + normalizedScope = normalizedScope.trimmingCharacters(in: CharacterSet(charactersIn: ".")) + return normalizedScope + } + + private static func scopeFromSocketPath(_ socketPath: String?) -> String? { + guard let socketPath = normalized(socketPath) else { + return nil + } + + let candidate = URL(fileURLWithPath: socketPath).lastPathComponent + let prefixes = ["cmux-debug-", "cmux-"] + for prefix in prefixes { + guard candidate.hasPrefix(prefix), candidate.hasSuffix(".sock") else { continue } + let start = candidate.index(candidate.startIndex, offsetBy: prefix.count) + let end = candidate.index(candidate.endIndex, offsetBy: -".sock".count) + guard start < end else { continue } + let rawScope = String(candidate[start.. String? { + if let tag = normalized(environment[SocketControlSettings.launchTagEnvKey]) { + let scoped = sanitizeScope(tag) + if !scoped.isEmpty { + return scoped + } + } + + if let scope = scopeFromSocketPath(environment["CMUX_SOCKET_PATH"]) { + return scope + } + + return scopeFromSocketPath( + SocketControlSettings.socketPath( + environment: environment, + bundleIdentifier: Bundle.main.bundleIdentifier + ) + ) + } + + private static func keychainService(environment: [String: String]) -> String { + guard let scope = keychainScope(environment: environment) else { + return service + } + return "\(service).\(scope)" + } + + private static func baseQuery(environment: [String: String]) -> [String: Any] { [ kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, + kSecAttrService as String: keychainService(environment: environment), kSecAttrAccount as String: account, ] } @@ -75,7 +145,7 @@ enum SocketControlPasswordStore { if let envPassword = environment[SocketControlSettings.socketPasswordEnvKey], !envPassword.isEmpty { return envPassword } - return try? loadPassword() + return try? loadPassword(environment: environment) } static func hasConfiguredPassword( @@ -95,8 +165,10 @@ enum SocketControlPasswordStore { return expected == candidate } - static func loadPassword() throws -> String? { - var query = baseQuery + static func loadPassword( + environment: [String: String] = ProcessInfo.processInfo.environment + ) throws -> String? { + var query = baseQuery(environment: environment) query[kSecReturnData as String] = true query[kSecMatchLimit as String] = kSecMatchLimitOne @@ -114,15 +186,19 @@ enum SocketControlPasswordStore { return String(data: data, encoding: .utf8) } - static func savePassword(_ password: String) throws { + static func savePassword( + _ password: String, + environment: [String: String] = ProcessInfo.processInfo.environment + ) throws { let normalized = password.trimmingCharacters(in: .newlines) if normalized.isEmpty { - try clearPassword() + try clearPassword(environment: environment) return } let data = Data(normalized.utf8) - var lookup = baseQuery + let scopedQuery = baseQuery(environment: environment) + var lookup = scopedQuery lookup[kSecReturnData as String] = true lookup[kSecMatchLimit as String] = kSecMatchLimitOne @@ -133,12 +209,12 @@ enum SocketControlPasswordStore { let attrsToUpdate: [String: Any] = [ kSecValueData as String: data ] - let updateStatus = SecItemUpdate(baseQuery as CFDictionary, attrsToUpdate as CFDictionary) + let updateStatus = SecItemUpdate(scopedQuery as CFDictionary, attrsToUpdate as CFDictionary) guard updateStatus == errSecSuccess else { throw NSError(domain: NSOSStatusErrorDomain, code: Int(updateStatus)) } case errSecItemNotFound: - var add = baseQuery + var add = scopedQuery add[kSecValueData as String] = data add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly let addStatus = SecItemAdd(add as CFDictionary, nil) @@ -150,8 +226,10 @@ enum SocketControlPasswordStore { } } - static func clearPassword() throws { - let status = SecItemDelete(baseQuery as CFDictionary) + static func clearPassword( + environment: [String: String] = ProcessInfo.processInfo.environment + ) throws { + let status = SecItemDelete(baseQuery(environment: environment) as CFDictionary) guard status == errSecSuccess || status == errSecItemNotFound else { throw NSError(domain: NSOSStatusErrorDomain, code: Int(status)) } diff --git a/tests/test_cli_version_flag.py b/tests/test_cli_version_flag.py index b48419f2..00499ce0 100644 --- a/tests/test_cli_version_flag.py +++ b/tests/test_cli_version_flag.py @@ -32,13 +32,17 @@ def resolve_cmux_cli() -> str: raise RuntimeError("Unable to find cmux CLI binary. Set CMUX_CLI_BIN.") -def run(cli_path: str, *args: str) -> tuple[int, str, str]: - proc = subprocess.run( - [cli_path, *args], - text=True, - capture_output=True, - check=False, - ) +def run(cli_path: str, *args: str, timeout: float = 5.0) -> tuple[int, str, str]: + try: + proc = subprocess.run( + [cli_path, *args], + text=True, + capture_output=True, + check=False, + timeout=timeout, + ) + except subprocess.TimeoutExpired: + return 124, "", f"timed out after {timeout:.1f}s" return proc.returncode, proc.stdout.strip(), proc.stderr.strip() diff --git a/tests/test_socket_password_keychain_scope.py b/tests/test_socket_password_keychain_scope.py new file mode 100644 index 00000000..2392d8c7 --- /dev/null +++ b/tests/test_socket_password_keychain_scope.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +"""Regression test: socket password keychain entries are scoped per debug instance.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + + +def get_repo_root() -> Path: + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + check=False, + ) + if result.returncode == 0: + return Path(result.stdout.strip()) + return Path.cwd() + + +def require(content: str, needle: str, message: str, failures: list[str]) -> None: + if needle not in content: + failures.append(message) + + +def reject(content: str, needle: str, message: str, failures: list[str]) -> None: + if needle in content: + failures.append(message) + + +def main() -> int: + repo_root = get_repo_root() + cli_path = repo_root / "CLI" / "cmux.swift" + settings_path = repo_root / "Sources" / "SocketControlSettings.swift" + + missing = [str(path) for path in (cli_path, settings_path) if not path.exists()] + if missing: + print("FAIL: missing expected files:") + for path in missing: + print(f"- {path}") + return 1 + + cli = cli_path.read_text(encoding="utf-8") + settings = settings_path.read_text(encoding="utf-8") + failures: list[str] = [] + + require( + cli, + "static func resolve(explicit: String?, socketPath: String) -> String?", + "CLI resolver must accept socketPath to determine scoped keychain service", + failures, + ) + require( + cli, + "private static func keychainServices(socketPath: String) -> [String]", + "CLI must derive keychain services from socket context", + failures, + ) + require( + cli, + 'return ["\\(service).\\(scope)"]', + "CLI should use only the scoped keychain service when scope is present", + failures, + ) + require( + cli, + "URL(fileURLWithPath: socketPath).lastPathComponent", + "CLI scope detection should parse the socket file name", + failures, + ) + require( + cli, + "kSecUseAuthenticationContext as String: authContext", + "CLI keychain lookup must fail fast without interactive keychain prompts", + failures, + ) + require( + cli, + "SocketPasswordResolver.resolve(explicit: socketPasswordArg, socketPath: socketPath)", + "CLI run path must pass socketPath into password resolution", + failures, + ) + + require( + settings, + "private static func keychainScope(environment: [String: String]) -> String?", + "App keychain store should compute a scoped keychain namespace", + failures, + ) + require( + settings, + "environment[SocketControlSettings.launchTagEnvKey]", + "App keychain scope should prioritize CMUX_TAG", + failures, + ) + require( + settings, + "URL(fileURLWithPath: socketPath).lastPathComponent", + "App keychain scope should parse the socket file name", + failures, + ) + require( + settings, + "private static func keychainService(environment: [String: String]) -> String", + "App keychain service should be derived from environment scope", + failures, + ) + require( + settings, + 'return "\\(service).\\(scope)"', + "App keychain service should append the scoped suffix", + failures, + ) + require( + settings, + "kSecAttrService as String: keychainService(environment: environment)", + "App keychain queries should use mode-specific scoped service", + failures, + ) + require( + settings, + "return try? loadPassword(environment: environment)", + "configuredPassword should read keychain from matching scoped service", + failures, + ) + + reject( + settings, + "private static var baseQuery: [String: Any]", + "Legacy global baseQuery should not remain as a static unscoped property", + failures, + ) + + if failures: + print("FAIL: keychain scope regression(s) detected") + for failure in failures: + print(f"- {failure}") + return 1 + + print("PASS: socket password keychain service is scoped by tagged debug instance") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())