diff --git a/CLI/cmux.swift b/CLI/cmux.swift index c1c98599..a6dff193 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -1,6 +1,5 @@ import Foundation import Darwin -import Security #if canImport(Sentry) import Sentry #endif @@ -416,17 +415,17 @@ enum CLIIDFormat: String { } private enum SocketPasswordResolver { - private static let service = "com.cmuxterm.app.socket-control" - private static let account = "local-socket-password" + private static let directoryName = "cmux" + private static let fileName = "socket-control-password" static func resolve(explicit: String?) -> String? { - if let explicit = normalized(explicit), !explicit.isEmpty { + if let explicit = normalized(explicit) { return explicit } - if let env = normalized(ProcessInfo.processInfo.environment["CMUX_SOCKET_PASSWORD"]), !env.isEmpty { + if let env = normalized(ProcessInfo.processInfo.environment["CMUX_SOCKET_PASSWORD"]) { return env } - return loadFromKeychain() + return loadFromFile() } private static func normalized(_ value: String?) -> String? { @@ -435,23 +434,20 @@ 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 { + private static func loadFromFile() -> String? { + guard let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return nil } - guard let data = result as? Data else { + let passwordURL = appSupport + .appendingPathComponent(directoryName, isDirectory: true) + .appendingPathComponent(fileName, isDirectory: false) + guard let data = try? Data(contentsOf: passwordURL) else { return nil } - return String(data: data, encoding: .utf8) + guard let value = String(data: data, encoding: .utf8) else { + return nil + } + return normalized(value) } } @@ -5261,7 +5257,7 @@ struct CMUXCLI { Output defaults to refs; pass --id-format uuids or --id-format both to include UUIDs. Socket Auth: - --password takes precedence, then CMUX_SOCKET_PASSWORD env var, then keychain password saved in Settings. + --password takes precedence, then CMUX_SOCKET_PASSWORD env var, then password saved in Settings. Commands: version diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 9604d4c2..aa0221e6 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -83,6 +83,7 @@ F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */; }; F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */; }; F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */; }; + F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -213,6 +214,7 @@ F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistenceTests.swift; sourceTree = ""; }; F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateShortcutRoutingTests.swift; sourceTree = ""; }; F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentViewVisibilityTests.swift; sourceTree = ""; }; + F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketControlPasswordStoreTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -425,6 +427,7 @@ F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */, F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */, F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */, + F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */, ); path = cmuxTests; sourceTree = ""; @@ -634,6 +637,7 @@ F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */, F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */, F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */, + F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Sources/SocketControlSettings.swift b/Sources/SocketControlSettings.swift index b9705095..42a9a130 100644 --- a/Sources/SocketControlSettings.swift +++ b/Sources/SocketControlSettings.swift @@ -1,5 +1,7 @@ import Foundation +#if canImport(Security) import Security +#endif enum SocketControlMode: String, CaseIterable, Identifiable { case off @@ -37,7 +39,7 @@ enum SocketControlMode: String, CaseIterable, Identifiable { case .automation: return "Allow external local automation clients from this macOS user (no ancestry check)." case .password: - return "Require socket authentication with a password stored in your keychain." + return "Require socket authentication with a password stored in a local file." case .allowAll: return "Allow any local process and user to connect with no auth. Unsafe." } @@ -58,103 +60,175 @@ enum SocketControlMode: String, CaseIterable, Identifiable { } enum SocketControlPasswordStore { - static let service = "com.cmuxterm.app.socket-control" - static let account = "local-socket-password" - - private static var baseQuery: [String: Any] { - [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: account, - ] - } + static let directoryName = "cmux" + static let fileName = "socket-control-password" + private static let keychainMigrationDefaultsKey = "socketControlPasswordMigrationVersion" + private static let keychainMigrationVersion = 1 + private static let legacyKeychainService = "com.cmuxterm.app.socket-control" + private static let legacyKeychainAccount = "local-socket-password" static func configuredPassword( - environment: [String: String] = ProcessInfo.processInfo.environment + environment: [String: String] = ProcessInfo.processInfo.environment, + fileURL: URL? = nil ) -> String? { - if let envPassword = environment[SocketControlSettings.socketPasswordEnvKey], !envPassword.isEmpty { + if let envPassword = normalized(environment[SocketControlSettings.socketPasswordEnvKey]) { return envPassword } - return try? loadPassword() + return try? loadPassword(fileURL: fileURL) } static func hasConfiguredPassword( - environment: [String: String] = ProcessInfo.processInfo.environment + environment: [String: String] = ProcessInfo.processInfo.environment, + fileURL: URL? = nil ) -> Bool { - guard let configured = configuredPassword(environment: environment) else { return false } + guard let configured = configuredPassword(environment: environment, fileURL: fileURL) else { return false } return !configured.isEmpty } static func verify( password candidate: String, - environment: [String: String] = ProcessInfo.processInfo.environment + environment: [String: String] = ProcessInfo.processInfo.environment, + fileURL: URL? = nil ) -> Bool { - guard let expected = configuredPassword(environment: environment), !expected.isEmpty else { + guard let expected = configuredPassword(environment: environment, fileURL: fileURL), !expected.isEmpty else { return false } return expected == candidate } - static func loadPassword() throws -> String? { - var query = baseQuery - query[kSecReturnData as String] = true - query[kSecMatchLimit as String] = kSecMatchLimitOne - - var result: CFTypeRef? - let status = SecItemCopyMatching(query as CFDictionary, &result) - if status == errSecItemNotFound { - return nil - } - guard status == errSecSuccess else { - throw NSError(domain: NSOSStatusErrorDomain, code: Int(status)) - } - guard let data = result as? Data else { - return nil - } - return String(data: data, encoding: .utf8) - } - - static func savePassword(_ password: String) throws { - let normalized = password.trimmingCharacters(in: .newlines) - if normalized.isEmpty { - try clearPassword() + static func migrateLegacyKeychainPasswordIfNeeded( + defaults: UserDefaults = .standard, + fileURL: URL? = nil, + loadLegacyPassword: () -> String? = { loadLegacyPasswordFromKeychain() }, + deleteLegacyPassword: () -> Bool = { deleteLegacyPasswordFromKeychain() } + ) { + guard defaults.integer(forKey: keychainMigrationDefaultsKey) < keychainMigrationVersion else { return } - let data = Data(normalized.utf8) - var lookup = baseQuery - lookup[kSecReturnData as String] = true - lookup[kSecMatchLimit as String] = kSecMatchLimitOne + guard let legacyPassword = normalized(loadLegacyPassword()) else { + defaults.set(keychainMigrationVersion, forKey: keychainMigrationDefaultsKey) + return + } - var existing: CFTypeRef? - let lookupStatus = SecItemCopyMatching(lookup as CFDictionary, &existing) - switch lookupStatus { - case errSecSuccess: - let attrsToUpdate: [String: Any] = [ - kSecValueData as String: data - ] - let updateStatus = SecItemUpdate(baseQuery as CFDictionary, attrsToUpdate as CFDictionary) - guard updateStatus == errSecSuccess else { - throw NSError(domain: NSOSStatusErrorDomain, code: Int(updateStatus)) + do { + if try loadPassword(fileURL: fileURL) == nil { + try savePassword(legacyPassword, fileURL: fileURL) } - case errSecItemNotFound: - var add = baseQuery - add[kSecValueData as String] = data - add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly - let addStatus = SecItemAdd(add as CFDictionary, nil) - guard addStatus == errSecSuccess else { - throw NSError(domain: NSOSStatusErrorDomain, code: Int(addStatus)) + guard deleteLegacyPassword() else { + return } - default: - throw NSError(domain: NSOSStatusErrorDomain, code: Int(lookupStatus)) + defaults.set(keychainMigrationVersion, forKey: keychainMigrationDefaultsKey) + } catch { + // Leave migration unset so it retries on next launch. } } - static func clearPassword() throws { - let status = SecItemDelete(baseQuery as CFDictionary) - guard status == errSecSuccess || status == errSecItemNotFound else { - throw NSError(domain: NSOSStatusErrorDomain, code: Int(status)) + static func loadPassword(fileURL: URL? = nil) throws -> String? { + guard let fileURL = fileURL ?? defaultPasswordFileURL() else { + return nil } + guard FileManager.default.fileExists(atPath: fileURL.path) else { + return nil + } + let data = try Data(contentsOf: fileURL) + guard let password = String(data: data, encoding: .utf8) else { + return nil + } + return normalized(password) + } + + static func savePassword(_ password: String, fileURL: URL? = nil) throws { + let normalized = password.trimmingCharacters(in: .newlines) + if normalized.isEmpty { + try clearPassword(fileURL: fileURL) + return + } + + guard let fileURL = fileURL ?? defaultPasswordFileURL() else { + throw NSError( + domain: NSCocoaErrorDomain, + code: NSFileNoSuchFileError, + userInfo: [NSLocalizedDescriptionKey: "Unable to resolve socket password file path."] + ) + } + let directory = fileURL.deletingLastPathComponent() + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + let data = Data(normalized.utf8) + try data.write(to: fileURL, options: .atomic) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: fileURL.path) + } + + static func clearPassword(fileURL: URL? = nil) throws { + guard let fileURL = fileURL ?? defaultPasswordFileURL() else { + return + } + guard FileManager.default.fileExists(atPath: fileURL.path) else { + return + } + try FileManager.default.removeItem(at: fileURL) + } + + static func defaultPasswordFileURL( + appSupportDirectory: URL? = nil, + fileManager: FileManager = .default + ) -> URL? { + let resolvedAppSupport: URL + if let appSupportDirectory { + resolvedAppSupport = appSupportDirectory + } else if let discovered = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first { + resolvedAppSupport = discovered + } else { + return nil + } + return resolvedAppSupport + .appendingPathComponent(directoryName, isDirectory: true) + .appendingPathComponent(fileName, isDirectory: false) + } + + private static func loadLegacyPasswordFromKeychain() -> String? { +#if canImport(Security) + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: legacyKeychainService, + kSecAttrAccount: legacyKeychainAccount, + kSecReturnData: true, + kSecMatchLimit: kSecMatchLimitOne, + ] + + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess, let data = result as? Data else { + return nil + } + return String(data: data, encoding: .utf8) +#else + return nil +#endif + } + + private static func deleteLegacyPasswordFromKeychain() -> Bool { +#if canImport(Security) + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: legacyKeychainService, + kSecAttrAccount: legacyKeychainAccount, + ] + let status = SecItemDelete(query as CFDictionary) + return status == errSecSuccess || status == errSecItemNotFound +#else + return false +#endif + } + + private static func normalized(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .newlines) + return trimmed.isEmpty ? nil : trimmed } } diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index dc3de77d..05cc9aef 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -56,6 +56,7 @@ struct cmuxApp: App { defaults.set(legacy ? SocketControlMode.cmuxOnly.rawValue : SocketControlMode.off.rawValue, forKey: SocketControlSettings.appStorageKey) } + SocketControlPasswordStore.migrateLegacyKeychainPasswordIfNeeded(defaults: defaults) migrateSidebarAppearanceDefaultsIfNeeded(defaults: defaults) // UI tests depend on AppDelegate wiring happening even if SwiftUI view appearance @@ -2751,7 +2752,7 @@ struct SettingsView: View { do { try SocketControlPasswordStore.savePassword(trimmed) socketPasswordDraft = "" - socketPasswordStatusMessage = "Password saved to keychain." + socketPasswordStatusMessage = "Password saved." socketPasswordStatusIsError = false } catch { socketPasswordStatusMessage = "Failed to save password (\(error.localizedDescription))." @@ -3063,7 +3064,7 @@ struct SettingsView: View { SettingsCardRow( "Socket Password", subtitle: hasSocketPasswordConfigured - ? "Stored in login keychain." + ? "Stored in Application Support." : "No password set. External clients will be blocked until one is configured." ) { HStack(spacing: 8) { diff --git a/cmuxTests/SocketControlPasswordStoreTests.swift b/cmuxTests/SocketControlPasswordStoreTests.swift new file mode 100644 index 00000000..548257b2 --- /dev/null +++ b/cmuxTests/SocketControlPasswordStoreTests.swift @@ -0,0 +1,111 @@ +import XCTest + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +final class SocketControlPasswordStoreTests: XCTestCase { + func testSaveLoadAndClearRoundTripUsesFileStorage() throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-socket-password-tests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let fileURL = tempDir.appendingPathComponent("socket-password.txt", isDirectory: false) + + XCTAssertFalse(SocketControlPasswordStore.hasConfiguredPassword(environment: [:], fileURL: fileURL)) + + try SocketControlPasswordStore.savePassword("hunter2", fileURL: fileURL) + XCTAssertEqual(try SocketControlPasswordStore.loadPassword(fileURL: fileURL), "hunter2") + XCTAssertTrue(SocketControlPasswordStore.hasConfiguredPassword(environment: [:], fileURL: fileURL)) + + try SocketControlPasswordStore.clearPassword(fileURL: fileURL) + XCTAssertNil(try SocketControlPasswordStore.loadPassword(fileURL: fileURL)) + XCTAssertFalse(SocketControlPasswordStore.hasConfiguredPassword(environment: [:], fileURL: fileURL)) + } + + func testConfiguredPasswordPrefersEnvironmentOverStoredFile() throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-socket-password-tests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let fileURL = tempDir.appendingPathComponent("socket-password.txt", isDirectory: false) + try SocketControlPasswordStore.savePassword("stored-secret", fileURL: fileURL) + + let environment = [SocketControlSettings.socketPasswordEnvKey: "env-secret"] + let configured = SocketControlPasswordStore.configuredPassword( + environment: environment, + fileURL: fileURL + ) + XCTAssertEqual(configured, "env-secret") + } + + func testDefaultPasswordFileURLUsesCmuxAppSupportPath() throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-socket-password-tests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let resolved = SocketControlPasswordStore.defaultPasswordFileURL(appSupportDirectory: tempDir) + XCTAssertEqual( + resolved?.path, + tempDir.appendingPathComponent("cmux", isDirectory: true) + .appendingPathComponent("socket-control-password", isDirectory: false).path + ) + } + + func testLegacyKeychainMigrationCopiesPasswordDeletesLegacyAndRunsOnlyOnce() throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-socket-password-tests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let fileURL = tempDir.appendingPathComponent("socket-password.txt", isDirectory: false) + let defaultsSuiteName = "cmux-socket-password-migration-tests-\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: defaultsSuiteName) else { + XCTFail("Expected isolated UserDefaults suite for migration test") + return + } + defer { defaults.removePersistentDomain(forName: defaultsSuiteName) } + + var lookupCount = 0 + var deleteCount = 0 + + SocketControlPasswordStore.migrateLegacyKeychainPasswordIfNeeded( + defaults: defaults, + fileURL: fileURL, + loadLegacyPassword: { + lookupCount += 1 + return "legacy-secret" + }, + deleteLegacyPassword: { + deleteCount += 1 + return true + } + ) + + XCTAssertEqual(try SocketControlPasswordStore.loadPassword(fileURL: fileURL), "legacy-secret") + XCTAssertEqual(lookupCount, 1) + XCTAssertEqual(deleteCount, 1) + + SocketControlPasswordStore.migrateLegacyKeychainPasswordIfNeeded( + defaults: defaults, + fileURL: fileURL, + loadLegacyPassword: { + lookupCount += 1 + return "new-value" + }, + deleteLegacyPassword: { + deleteCount += 1 + return true + } + ) + + XCTAssertEqual(lookupCount, 1) + XCTAssertEqual(deleteCount, 1) + XCTAssertEqual(try SocketControlPasswordStore.loadPassword(fileURL: fileURL), "legacy-secret") + } +}