diff --git a/Sources/SocketControlSettings.swift b/Sources/SocketControlSettings.swift index 42a9a130..33babafe 100644 --- a/Sources/SocketControlSettings.swift +++ b/Sources/SocketControlSettings.swift @@ -66,31 +66,65 @@ enum SocketControlPasswordStore { private static let keychainMigrationVersion = 1 private static let legacyKeychainService = "com.cmuxterm.app.socket-control" private static let legacyKeychainAccount = "local-socket-password" + private struct LazyKeychainFallbackCache { + var hasLoaded = false + var password: String? + } + private static let lazyKeychainFallbackLock = NSLock() + private static var lazyKeychainFallbackCache = LazyKeychainFallbackCache() static func configuredPassword( environment: [String: String] = ProcessInfo.processInfo.environment, - fileURL: URL? = nil + fileURL: URL? = nil, + allowLazyKeychainFallback: Bool = false, + loadKeychainPassword: () -> String? = { loadLegacyPasswordFromKeychain() } ) -> String? { if let envPassword = normalized(environment[SocketControlSettings.socketPasswordEnvKey]) { return envPassword } - return try? loadPassword(fileURL: fileURL) + let filePassword: String? + do { + filePassword = try loadPassword(fileURL: fileURL) + } catch { + filePassword = nil + } + if let filePassword { + return filePassword + } + guard allowLazyKeychainFallback else { + return nil + } + return cachedLazyKeychainFallbackPassword(loadKeychainPassword: loadKeychainPassword) } static func hasConfiguredPassword( environment: [String: String] = ProcessInfo.processInfo.environment, - fileURL: URL? = nil + fileURL: URL? = nil, + allowLazyKeychainFallback: Bool = false, + loadKeychainPassword: () -> String? = { loadLegacyPasswordFromKeychain() } ) -> Bool { - guard let configured = configuredPassword(environment: environment, fileURL: fileURL) else { return false } + guard let configured = configuredPassword( + environment: environment, + fileURL: fileURL, + allowLazyKeychainFallback: allowLazyKeychainFallback, + loadKeychainPassword: loadKeychainPassword + ) else { return false } return !configured.isEmpty } static func verify( password candidate: String, environment: [String: String] = ProcessInfo.processInfo.environment, - fileURL: URL? = nil + fileURL: URL? = nil, + allowLazyKeychainFallback: Bool = false, + loadKeychainPassword: () -> String? = { loadLegacyPasswordFromKeychain() } ) -> Bool { - guard let expected = configuredPassword(environment: environment, fileURL: fileURL), !expected.isEmpty else { + guard let expected = configuredPassword( + environment: environment, + fileURL: fileURL, + allowLazyKeychainFallback: allowLazyKeychainFallback, + loadKeychainPassword: loadKeychainPassword + ), !expected.isEmpty else { return false } return expected == candidate @@ -173,6 +207,12 @@ enum SocketControlPasswordStore { try FileManager.default.removeItem(at: fileURL) } + static func resetLazyKeychainFallbackCacheForTests() { + lazyKeychainFallbackLock.lock() + lazyKeychainFallbackCache = LazyKeychainFallbackCache() + lazyKeychainFallbackLock.unlock() + } + static func defaultPasswordFileURL( appSupportDirectory: URL? = nil, fileManager: FileManager = .default @@ -225,6 +265,19 @@ enum SocketControlPasswordStore { #endif } + private static func cachedLazyKeychainFallbackPassword( + loadKeychainPassword: () -> String? + ) -> String? { + lazyKeychainFallbackLock.lock() + defer { lazyKeychainFallbackLock.unlock() } + if lazyKeychainFallbackCache.hasLoaded { + return lazyKeychainFallbackCache.password + } + lazyKeychainFallbackCache.hasLoaded = true + lazyKeychainFallbackCache.password = normalized(loadKeychainPassword()) + return lazyKeychainFallbackCache.password + } + private static func normalized(_ value: String?) -> String? { guard let value else { return nil } let trimmed = value.trimmingCharacters(in: .newlines) diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 5899fc6f..81b1f9d6 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -459,7 +459,7 @@ class TerminalController { guard lowered == "auth" || lowered.hasPrefix("auth ") else { return nil } - guard SocketControlPasswordStore.hasConfiguredPassword() else { + guard SocketControlPasswordStore.hasConfiguredPassword(allowLazyKeychainFallback: true) else { return "ERROR: Password mode is enabled but no socket password is configured in Settings." } @@ -472,7 +472,7 @@ class TerminalController { guard !provided.isEmpty else { return "ERROR: Missing password. Usage: auth " } - guard SocketControlPasswordStore.verify(password: provided) else { + guard SocketControlPasswordStore.verify(password: provided, allowLazyKeychainFallback: true) else { return "ERROR: Invalid password" } authenticated = true @@ -496,7 +496,7 @@ class TerminalController { return v2Error(id: id, code: "invalid_params", message: "auth.login requires params.password") } - guard SocketControlPasswordStore.hasConfiguredPassword() else { + guard SocketControlPasswordStore.hasConfiguredPassword(allowLazyKeychainFallback: true) else { return v2Error( id: id, code: "auth_unconfigured", @@ -504,7 +504,7 @@ class TerminalController { ) } - guard SocketControlPasswordStore.verify(password: provided) else { + guard SocketControlPasswordStore.verify(password: provided, allowLazyKeychainFallback: true) else { return v2Error(id: id, code: "auth_failed", message: "Invalid password") } authenticated = true diff --git a/cmuxTests/SocketControlPasswordStoreTests.swift b/cmuxTests/SocketControlPasswordStoreTests.swift index 548257b2..fca99fe1 100644 --- a/cmuxTests/SocketControlPasswordStoreTests.swift +++ b/cmuxTests/SocketControlPasswordStoreTests.swift @@ -7,6 +7,16 @@ import XCTest #endif final class SocketControlPasswordStoreTests: XCTestCase { + override func setUp() { + super.setUp() + SocketControlPasswordStore.resetLazyKeychainFallbackCacheForTests() + } + + override func tearDown() { + SocketControlPasswordStore.resetLazyKeychainFallbackCacheForTests() + super.tearDown() + } + func testSaveLoadAndClearRoundTripUsesFileStorage() throws { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-socket-password-tests-\(UUID().uuidString)", isDirectory: true) @@ -43,6 +53,127 @@ final class SocketControlPasswordStoreTests: XCTestCase { XCTAssertEqual(configured, "env-secret") } + func testConfiguredPasswordLazyKeychainFallbackReadsOnlyOnceAndCaches() { + var readCount = 0 + + let withoutFallback = SocketControlPasswordStore.configuredPassword( + environment: [:], + fileURL: nil, + allowLazyKeychainFallback: false, + loadKeychainPassword: { + readCount += 1 + return "legacy-secret" + } + ) + XCTAssertNil(withoutFallback) + XCTAssertEqual(readCount, 0) + + let firstWithFallback = SocketControlPasswordStore.configuredPassword( + environment: [:], + fileURL: nil, + allowLazyKeychainFallback: true, + loadKeychainPassword: { + readCount += 1 + return "legacy-secret" + } + ) + XCTAssertEqual(firstWithFallback, "legacy-secret") + XCTAssertEqual(readCount, 1) + + let secondWithFallback = SocketControlPasswordStore.configuredPassword( + environment: [:], + fileURL: nil, + allowLazyKeychainFallback: true, + loadKeychainPassword: { + readCount += 1 + return "new-secret" + } + ) + XCTAssertEqual(secondWithFallback, "legacy-secret") + XCTAssertEqual(readCount, 1) + } + + func testConfiguredPasswordLazyKeychainFallbackCachesMissingValue() { + var readCount = 0 + + let first = SocketControlPasswordStore.configuredPassword( + environment: [:], + fileURL: nil, + allowLazyKeychainFallback: true, + loadKeychainPassword: { + readCount += 1 + return nil + } + ) + XCTAssertNil(first) + XCTAssertEqual(readCount, 1) + + let second = SocketControlPasswordStore.configuredPassword( + environment: [:], + fileURL: nil, + allowLazyKeychainFallback: true, + loadKeychainPassword: { + readCount += 1 + return "should-not-be-read" + } + ) + XCTAssertNil(second) + XCTAssertEqual(readCount, 1) + } + + func testConfiguredPasswordPrefersStoredFileOverLazyKeychainFallback() 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) + + var readCount = 0 + let configured = SocketControlPasswordStore.configuredPassword( + environment: [:], + fileURL: fileURL, + allowLazyKeychainFallback: true, + loadKeychainPassword: { + readCount += 1 + return "legacy-secret" + } + ) + + XCTAssertEqual(configured, "stored-secret") + XCTAssertEqual(readCount, 0) + } + + func testHasConfiguredAndVerifyReuseSingleLazyKeychainRead() { + var readCount = 0 + let loader = { + readCount += 1 + return "legacy-secret" + } + + XCTAssertTrue( + SocketControlPasswordStore.hasConfiguredPassword( + environment: [:], + fileURL: nil, + allowLazyKeychainFallback: true, + loadKeychainPassword: loader + ) + ) + XCTAssertEqual(readCount, 1) + + XCTAssertTrue( + SocketControlPasswordStore.verify( + password: "legacy-secret", + environment: [:], + fileURL: nil, + allowLazyKeychainFallback: true, + loadKeychainPassword: loader + ) + ) + XCTAssertEqual(readCount, 1) + } + func testDefaultPasswordFileURLUsesCmuxAppSupportPath() throws { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-socket-password-tests-\(UUID().uuidString)", isDirectory: true)