Restore lazy keychain reads for socket password (#589)
Add a cached lazy keychain fallback to SocketControlPasswordStore so that authentication paths in TerminalController can transparently read a legacy keychain password without blocking on every request. The keychain is read at most once and the result is cached behind an NSLock. File-based and environment passwords still take priority. Closes https://github.com/manaflow-ai/cmux/issues/579
This commit is contained in:
parent
df119c75d5
commit
847ce008ed
3 changed files with 194 additions and 10 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 <password>"
|
||||
}
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue