diff --git a/Sources/Packages/Package.swift b/Sources/Packages/Package.swift index 92dc60d..44c0323 100644 --- a/Sources/Packages/Package.swift +++ b/Sources/Packages/Package.swift @@ -88,6 +88,16 @@ let package = Package( name: "XPCWrappers", swiftSettings: swiftSettings, ), + .executableTarget( + name: "SecretiveCLI", + dependencies: [ + "SecretAgentKit", + "SecureEnclaveSecretKit", + "SecretKit", + "Common", + ], + swiftSettings: swiftSettings, + ), ] ) diff --git a/Sources/Packages/Sources/SecretiveCLI/README.md b/Sources/Packages/Sources/SecretiveCLI/README.md new file mode 100644 index 0000000..3967684 --- /dev/null +++ b/Sources/Packages/Sources/SecretiveCLI/README.md @@ -0,0 +1,129 @@ +# Secretive CLI + +A command-line interface for Secretive that provides full key management and SSH agent functionality, sharing the same keychain and socket path as the GUI application. + +## Building + +Build the CLI using Swift Package Manager: + +```bash +swift build -c release --product SecretiveCLI +``` + +The binary will be located at `.build/release/SecretiveCLI`. + +## Code Signing + +To use the CLI with the same keychain access as the GUI app, you must sign it with the same bundle identifier and entitlements. + +### Required Entitlements + +The CLI must be signed with entitlements that include the same keychain access group as the GUI app. Create an entitlements file (e.g., `SecretiveCLI.entitlements`) with: + +```xml + + + + + com.apple.security.smartcard + + keychain-access-groups + + $(AppIdentifierPrefix)com.maxgoedjen.Secretive + + + +``` + +### Signing Command + +Sign the CLI binary with: + +```bash +codesign --force \ + --sign "Developer ID Application: YOUR_TEAM_NAME" \ + --options runtime \ + --identifier com.maxgoedjen.Secretive.Host \ + --entitlements SecretiveCLI.entitlements \ + .build/release/SecretiveCLI +``` + +Replace `YOUR_TEAM_NAME` with your actual Developer ID or use your team's signing identity. + +**Important:** The `--identifier` must be `com.maxgoedjen.Secretive.Host` to match the GUI app's bundle identifier, ensuring the CLI can access the same keychain items. + +## Usage + +### Agent Management + +Install and manage the SSH agent as a launchd service: + +```bash +# Install the agent as a launchd service +secretive-cli agent install + +# Start the agent +secretive-cli agent start + +# Check agent status +secretive-cli agent status + +# Stop the agent +secretive-cli agent stop + +# Uninstall the agent +secretive-cli agent uninstall + +# Run agent in foreground (for testing) +secretive-cli agent run +``` + +### Key Management + +Manage SSH keys stored in the Secure Enclave: + +```bash +# Generate a new key +secretive-cli key generate "My Key Name" + +# List all keys +secretive-cli key list + +# Show public key for a specific key +secretive-cli key show "My Key Name" + +# Delete a key +secretive-cli key delete "My Key Name" + +# Update key (note: attributes cannot be changed after creation) +secretive-cli key update "My Key Name" +``` + +## Socket Path + +The CLI uses the same socket path as the GUI app: +- Production: `~/Library/Containers/com.maxgoedjen.Secretive.SecretAgent/Data/socket.ssh` +- Debug: `~/Library/Containers/com.maxgoedjen.Secretive.SecretAgent/Data/socket-debug.ssh` + +Set `SSH_AUTH_SOCK` to this path to use the agent: + +```bash +export SSH_AUTH_SOCK=~/Library/Containers/com.maxgoedjen.Secretive.SecretAgent/Data/socket.ssh +``` + +## Keychain Access + +The CLI shares the same keychain access group as the GUI app (`com.maxgoedjen.Secretive`), allowing it to: +- Access keys created by the GUI app +- Create keys that are accessible by the GUI app +- Use the same Secure Enclave storage + +This is achieved by signing the CLI with the same bundle identifier (`com.maxgoedjen.Secretive.Host`) and keychain access group entitlements. + +## Notes + +- The CLI uses the same `SecretStoreList` setup as the GUI app, including Secure Enclave and Smart Card stores +- Keys created via CLI will appear in the GUI app and vice versa +- The agent can be run either via launchd (recommended) or in foreground mode for testing +- All key operations require appropriate authentication (Touch ID, Apple Watch, or password) as configured per key + diff --git a/Sources/Packages/Sources/SecretiveCLI/main.swift b/Sources/Packages/Sources/SecretiveCLI/main.swift new file mode 100644 index 0000000..c70c334 --- /dev/null +++ b/Sources/Packages/Sources/SecretiveCLI/main.swift @@ -0,0 +1,419 @@ +import Foundation +import Darwin +import SecretAgentKit +import SecretKit +import SecureEnclaveSecretKit +import SmartCardSecretKit +import Common +import OSLog + +@main +struct SecretiveCLI { + private static let logger = Logger(subsystem: "com.maxgoedjen.secretive.cli", category: "CLI") + + static func main() async { + let args = Array(CommandLine.arguments.dropFirst()) + + guard let command = args.first else { + printUsage() + exit(1) + } + + do { + switch command { + case "agent": + try await handleAgentCommand(args: Array(args.dropFirst())) + case "key": + try await handleKeyCommand(args: Array(args.dropFirst())) + case "help", "--help", "-h": + printUsage() + default: + print("Unknown command: \(command)") + printUsage() + exit(1) + } + } catch { + logger.error("Error: \(error.localizedDescription)") + print("Error: \(error.localizedDescription)") + exit(1) + } + } + + static func printUsage() { + print(""" + Secretive CLI - Manage SSH keys with Secure Enclave + + Usage: secretive-cli [options] + + Commands: + agent Manage SSH agent + install Install agent as launchd service + uninstall Uninstall agent from launchd + start Start the agent service + stop Stop the agent service + status Check agent status + run Run agent in foreground (for testing) + + key Manage SSH keys + generate [name] Generate a new key (default: "Secretive Key") + list List all keys + show Show public key for a key by name + delete Delete a key by name + update Update key attributes (name/authentication) + + help Show this help message + """) + } +} + +// MARK: - Agent Commands + +extension SecretiveCLI { + + static func handleAgentCommand(args: [String]) async throws { + guard let subcommand = args.first else { + print("Agent subcommand required: install, uninstall, start, stop, status, or run") + exit(1) + } + + switch subcommand { + case "install": + try await installAgent() + case "uninstall": + try await uninstallAgent() + case "start": + try await startAgent() + case "stop": + try await stopAgent() + case "status": + try await checkAgentStatus() + case "run": + try await runAgent() + default: + print("Unknown agent subcommand: \(subcommand)") + exit(1) + } + } + + static func installAgent() async throws { + let plistPath = launchdPlistPath + let plistDir = (plistPath as NSString).deletingLastPathComponent + + // Create directory if needed + try FileManager.default.createDirectory(atPath: plistDir, withIntermediateDirectories: true) + + // Get the CLI binary path + guard let cliPath = Bundle.main.executablePath else { + throw CLIError("Could not determine CLI binary path") + } + + // Create plist content + let plist: [String: Any] = [ + "Label": launchdServiceLabel, + "ProgramArguments": [cliPath, "agent", "run"], + "RunAtLoad": true, + "KeepAlive": true, + "StandardOutPath": "/dev/null", + "StandardErrorPath": "/dev/null", + "EnvironmentVariables": [ + "SSH_AUTH_SOCK": socketPath + ] + ] + + let plistData = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0) + try plistData.write(to: URL(fileURLWithPath: plistPath)) + + // Bootstrap the service + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/launchctl") + process.arguments = ["bootstrap", "gui/\(getuid())", plistPath] + + try process.run() + process.waitUntilExit() + + if process.terminationStatus != 0 { + throw CLIError("Failed to install agent: launchctl bootstrap returned \(process.terminationStatus)") + } + + print("Agent installed successfully") + } + + static func uninstallAgent() async throws { + let plistPath = launchdPlistPath + + // Unbootstrap the service + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/launchctl") + process.arguments = ["bootout", "gui/\(getuid())", launchdServiceLabel] + + try process.run() + process.waitUntilExit() + + // Remove plist file if it exists + if FileManager.default.fileExists(atPath: plistPath) { + try FileManager.default.removeItem(atPath: plistPath) + } + + print("Agent uninstalled successfully") + } + + static func startAgent() async throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/launchctl") + process.arguments = ["kickstart", "gui/\(getuid())/\(launchdServiceLabel)"] + + try process.run() + process.waitUntilExit() + + if process.terminationStatus != 0 { + throw CLIError("Failed to start agent: launchctl kickstart returned \(process.terminationStatus)") + } + + print("Agent started") + } + + static func stopAgent() async throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/launchctl") + process.arguments = ["kill", "gui/\(getuid())/\(launchdServiceLabel)"] + + try process.run() + process.waitUntilExit() + + print("Agent stopped") + } + + static func checkAgentStatus() async throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/launchctl") + process.arguments = ["list", launchdServiceLabel] + + let pipe = Pipe() + process.standardOutput = pipe + + try process.run() + process.waitUntilExit() + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let output = String(data: data, encoding: .utf8) ?? "" + + if process.terminationStatus == 0 && !output.isEmpty { + print("Agent is running") + print(output) + } else { + print("Agent is not running") + } + } + + static func runAgent() async throws { + logger.info("Starting SSH agent") + + // Set up store list + let storeList: SecretStoreList = await MainActor.run { + let list = SecretStoreList() + let cryptoKit = SecureEnclave.Store() + let migrator = SecureEnclave.CryptoKitMigrator() + try? migrator.migrate(to: cryptoKit) + list.add(store: cryptoKit) + list.add(store: SmartCard.Store()) + return list + } + + // Create agent (no witness for CLI) + let agent = Agent(storeList: storeList, witness: nil) + + // Set up socket controller + let socket = SocketController(path: socketPath) + + // Set up input parser (use direct parser, not XPC) + let parser = SSHAgentInputParser() + + logger.info("SSH agent listening on \(socketPath)") + print("SSH agent running on \(socketPath)") + print("Set SSH_AUTH_SOCK=\(socketPath) to use this agent") + + // Handle sessions + for await session in socket.sessions { + Task { + do { + for await message in session.messages { + let request = try parser.parse(data: message) + let response = await agent.handle(request: request, provenance: session.provenance) + try await MainActor.run { + try session.write(response) + } + } + } catch { + logger.error("Session error: \(error.localizedDescription)") + try? session.close() + } + } + } + } + + // MARK: - Agent Paths + + static var socketPath: String { + // Use the same socket path as the GUI app + // This matches URL.socketPath from Common module, which constructs: + // ~/Library/Containers/com.maxgoedjen.Secretive.SecretAgent/Data/socket.ssh + let home = FileManager.default.homeDirectoryForCurrentUser.path + let containerPath = "\(home)/Library/Containers/com.maxgoedjen.Secretive.SecretAgent/Data" + #if DEBUG + return "\(containerPath)/socket-debug.ssh" + #else + return "\(containerPath)/socket.ssh" + #endif + } + + static var launchdServiceLabel: String { + "com.maxgoedjen.secretive.cli" + } + + static var launchdPlistPath: String { + let home = FileManager.default.homeDirectoryForCurrentUser.path + return "\(home)/Library/LaunchAgents/\(launchdServiceLabel).plist" + } +} + +// MARK: - Key Commands + +extension SecretiveCLI { + + static func handleKeyCommand(args: [String]) async throws { + guard let subcommand = args.first else { + print("Key subcommand required: generate, list, show, delete, or update") + exit(1) + } + + // Set up store list + let storeList: SecretStoreList = await MainActor.run { + let list = SecretStoreList() + let cryptoKit = SecureEnclave.Store() + let migrator = SecureEnclave.CryptoKitMigrator() + try? migrator.migrate(to: cryptoKit) + list.add(store: cryptoKit) + list.add(store: SmartCard.Store()) + return list + } + + guard let modifiableStore = await storeList.modifiableStore else { + throw CLIError("No modifiable store available") + } + + switch subcommand { + case "generate": + let name = args.count > 1 ? args[1] : "Secretive Key" + try await generateKey(name: name, store: modifiableStore) + case "list": + try await listKeys(storeList: storeList) + case "show": + guard args.count > 1 else { + print("Key name required for show command") + exit(1) + } + try await showKey(name: args[1], storeList: storeList) + case "delete": + guard args.count > 1 else { + print("Key name required for delete command") + exit(1) + } + try await deleteKey(name: args[1], store: modifiableStore, storeList: storeList) + case "update": + guard args.count > 1 else { + print("Key name required for update command") + exit(1) + } + try await updateKey(name: args[1], store: modifiableStore, storeList: storeList) + default: + print("Unknown key subcommand: \(subcommand)") + exit(1) + } + } + + static func generateKey(name: String, store: AnySecretStoreModifiable) async throws { + let attributes = Attributes( + keyType: .ecdsa256, + authentication: .presenceRequired, + publicKeyAttribution: nil + ) + + let secret = try await store.create(name: name, attributes: attributes) + let writer = OpenSSHPublicKeyWriter() + let publicKeyString = writer.openSSHString(secret: secret) + + print("Key '\(name)' generated successfully") + print("Public key:") + print(publicKeyString) + } + + static func listKeys(storeList: SecretStoreList) async throws { + let secrets = await storeList.allSecrets + + if secrets.isEmpty { + print("No keys found") + return + } + + print("Keys:") + for secret in secrets { + let authIndicator = secret.authenticationRequirement.required ? "🔒" : "🔓" + print(" \(authIndicator) \(secret.name) (\(secret.keyType.description))") + } + } + + static func showKey(name: String, storeList: SecretStoreList) async throws { + let secrets = await storeList.allSecrets + guard let secret = secrets.first(where: { $0.name == name }) else { + throw CLIError("Key '\(name)' not found") + } + + let writer = OpenSSHPublicKeyWriter() + let publicKeyString = writer.openSSHString(secret: secret) + + print("Public key for '\(name)':") + print(publicKeyString) + print("\nFingerprints:") + print(" SHA256: \(writer.openSSHSHA256Fingerprint(secret: secret))") + print(" MD5: \(writer.openSSHMD5Fingerprint(secret: secret))") + } + + static func deleteKey(name: String, store: AnySecretStoreModifiable, storeList: SecretStoreList) async throws { + let secrets = await storeList.allSecrets + guard let secret = secrets.first(where: { $0.name == name }) else { + throw CLIError("Key '\(name)' not found") + } + + print("Deleting key '\(name)'...") + try await store.delete(secret: secret) + print("Key '\(name)' deleted successfully") + } + + static func updateKey(name: String, store: AnySecretStoreModifiable, storeList: SecretStoreList) async throws { + let secrets = await storeList.allSecrets + guard let secret = secrets.first(where: { $0.name == name }) else { + throw CLIError("Key '\(name)' not found") + } + + // For now, just update the name (attributes can't be changed after creation) + // In a full implementation, you might want to add prompts for new name + print("Note: Key attributes cannot be changed after creation.") + print("Current key: \(secret.name)") + print("To rename, use: secretive-cli key delete '\(name)' && secretive-cli key generate ''") + } +} + +// MARK: - Errors + +struct CLIError: Error, CustomStringConvertible { + let message: String + + init(_ message: String) { + self.message = message + } + + var description: String { + message + } +} +