Replace keychain password storage with file-based storage (#576)

Moves socket control password from the macOS login keychain to a
plain file at ~/Library/Application Support/cmux/socket-control-password.
This eliminates the system keychain prompt that interrupts users on
first launch or after keychain changes.

- Directory created with 0700, file written with 0600 permissions
- One-time migration copies existing keychain password to the file,
  deletes the keychain entry, and records a migration version in
  UserDefaults so it runs only once
- CLI SocketPasswordResolver also reads from the file path
- Security framework import is now conditional (#if canImport)
- Adds SocketControlPasswordStoreTests covering round-trip, env
  priority, path resolution, and migration behavior

Fixes https://github.com/manaflow-ai/cmux/issues/541
This commit is contained in:
Lawrence Chen 2026-02-26 14:29:12 -08:00 committed by GitHub
parent 780f959a48
commit 163f8572e4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 276 additions and 90 deletions

View file

@ -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

View file

@ -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 = "<group>"; };
F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateShortcutRoutingTests.swift; sourceTree = "<group>"; };
F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentViewVisibilityTests.swift; sourceTree = "<group>"; };
F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketControlPasswordStoreTests.swift; sourceTree = "<group>"; };
/* 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 = "<group>";
@ -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;
};

View file

@ -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
}
}

View file

@ -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) {

View file

@ -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")
}
}