Scope socket password keychain per debug run and harden CLI lookup
This commit is contained in:
parent
f16d8f36e7
commit
2714d07c9f
4 changed files with 324 additions and 37 deletions
|
|
@ -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'") {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
||||
|
|
|
|||
146
tests/test_socket_password_keychain_scope.py
Normal file
146
tests/test_socket_password_keychain_scope.py
Normal 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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue