Scope socket password keychain per debug run and harden CLI lookup

This commit is contained in:
Lawrence Chen 2026-02-25 00:30:39 -08:00
parent f16d8f36e7
commit 2714d07c9f
4 changed files with 324 additions and 37 deletions

View file

@ -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..<end])
let scoped = sanitizeScope(rawScope)
if !scoped.isEmpty {
return scoped
}
}
return nil
}
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 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'") {

View file

@ -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..<end])
let scoped = sanitizeScope(rawScope)
if !scoped.isEmpty {
return scoped
}
}
return nil
}
private static func keychainScope(environment: [String: String]) -> 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))
}

View file

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

View file

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