commit 66d1f7f28db784148d1f77c5020a882d70f35840 Author: Chris Sewell Date: Fri Feb 7 14:56:23 2025 -0500 Initial commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..c822404 --- /dev/null +++ b/README.md @@ -0,0 +1,128 @@ +# SSH Public Key Manager + +A minimal, user-friendly command-line tool for managing SSH public keys across multiple devices. This tool helps you maintain a centralized repository of SSH keys with features for capturing, deploying, and managing keys both locally and across systems. + +## Features + +- **Minimal Dependencies**: Uses only Python standard library +- **Git Integration**: Automatically syncs your keys with a git repository +- **Key Management**: + - Capture public keys from current system + - Deploy keys to current system + - Set aliases for easy identification + - Set expiry dates for keys + - Search and filter keys + - Copy keys to clipboard + - Automatic backups before changes + +- **System Key Management**: + - List all SSH keys on current system + - Rename key files (handles both public and private keys) + - Delete keys safely + - Copy system keys to clipboard + +## Requirements + +- Python 3.6+ +- Git (for sync functionality) +- tkinter (optional, for clipboard functionality) + +## Installation + +⚠️ **IMPORTANT**: Always use a private repository for storing SSH keys. Never store SSH keys in a public repository. + +1. Create a new private repository for your SSH keys, then clone it: + ```bash + # First create a private repository on GitHub/GitLab/etc + git clone + cd pubkeys + ``` + +2. Make the script executable: + ```bash + chmod +x manage_keys.py + ``` + +3. The tool will check if your repository is public and warn you if it is. For security: + - Ensure your repository is private before proceeding + - Regularly verify your repository's privacy settings + - Never change a repository containing SSH keys to public + +## Usage + +Run the tool: +```bash +./manage_keys.py +``` + +### Main Menu Options + +1. **Capture public key of current device** + - Lists all public keys found in your ~/.ssh directory + - Select which keys to add to the repository + - Set optional aliases and expiry dates + - Detects and handles duplicate keys + +2. **Deploy public keys to current device** + - Select keys to deploy to your local ~/.ssh/authorized_keys + - Verifies successful deployment + - Sets proper file permissions + +3. **Set alias for key** + - Add friendly names to your keys + - Makes keys easier to identify + +4. **Sync with git** + - Manually sync changes with git repository + - Automatic commit messages for tracking changes + +5. **Delete keys** + - Remove keys from the repository + - Requires confirmation to prevent accidents + +6. **List all keys** + - View all keys with their metadata + - Shows aliases, types, and expiry dates + +7. **Search keys** + - Find keys by name or alias + +8. **Copy key to clipboard** + - Quick access to key content + - Falls back to display if clipboard unavailable + +9. **Set key expiry** + - Add expiration dates to keys + - Helps with key rotation policies + +10. **Manage system keys** + - View all SSH keys on your system + - Rename key files + - Safely delete keys + - Copy system keys to clipboard + +### File Structure + +- `authorized_keys`: Main storage file for SSH public keys +- `key_aliases.json`: Stores metadata (aliases, expiry dates, etc.) +- `.key_backups/`: Directory containing timestamped backups + +### Security Features + +- Repository visibility check (warns if public) +- Automatic backup creation before changes +- Proper file permissions (600 for authorized_keys) +- Safe key deletion with confirmation +- Verification of key deployments + +## Best Practices + +1. **Keep Repository Private**: Never store SSH keys in a public repository +2. **Regular Backups**: The tool automatically creates backups, but consider external backups too +3. **Key Rotation**: Use the expiry date feature to track when keys need rotation +4. **Descriptive Aliases**: Use meaningful aliases to easily identify keys +5. **Regular Syncing**: Keep the repository updated across all systems + +## Contributing + +Feel free to submit issues and enhancement requests! diff --git a/manage_keys.py b/manage_keys.py new file mode 100755 index 0000000..bc934d3 --- /dev/null +++ b/manage_keys.py @@ -0,0 +1,674 @@ +#!/usr/bin/env python3 +import os +import subprocess +import sys +from typing import List, Dict, Optional +import re +import json +from datetime import datetime +import shutil + +# Suppress Tk deprecation warning on macOS +os.environ['TK_SILENCE_DEPRECATION'] = '1' + +try: + import tkinter as tk + from tkinter import messagebox + CLIPBOARD_AVAILABLE = True +except ImportError: + CLIPBOARD_AVAILABLE = False + +class SSHKeyManager: + def __init__(self): + self.keys_file = "authorized_keys" + self.aliases_file = "key_aliases.json" + self.backup_dir = ".key_backups" + self.aliases: Dict[str, dict] = {} + self.load_keys() + self.load_aliases() + + # Check repository visibility + if self.check_repo_visibility(): + print("\n⚠️ WARNING: This appears to be a public repository!") + print("It is strongly recommended to keep SSH keys in a private repository.") + print("Please consider making this repository private or using a different repository.") + confirm = input("\nDo you wish to continue anyway? (yes/N): ").lower() + if confirm != 'yes': + print("Exiting for security...") + sys.exit(1) + + def load_aliases(self) -> None: + """Load aliases and metadata from JSON file.""" + if os.path.exists(self.aliases_file): + try: + with open(self.aliases_file, 'r') as f: + self.aliases = json.load(f) + except json.JSONDecodeError: + print("Warning: Could not load aliases file") + + def save_aliases(self) -> None: + """Save aliases and metadata to JSON file.""" + with open(self.aliases_file, 'w') as f: + json.dump(self.aliases, f, indent=2) + + def create_backup(self) -> None: + """Create a backup of the keys file.""" + if not os.path.exists(self.backup_dir): + os.makedirs(self.backup_dir) + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + backup_file = os.path.join(self.backup_dir, f"authorized_keys_{timestamp}") + shutil.copy2(self.keys_file, backup_file) + print(f"Backup created: {backup_file}") + + def load_keys(self) -> None: + """Load existing keys.""" + if not os.path.exists(self.keys_file): + self.keys = [] + return + + with open(self.keys_file, 'r') as f: + self.keys = [line.strip() for line in f if line.strip()] + + def save_keys(self) -> None: + """Save keys to the authorized_keys file.""" + self.create_backup() + with open(self.keys_file, 'w') as f: + f.write('\n'.join(self.keys) + '\n') + self.save_aliases() + + def copy_to_clipboard(self, key: str) -> None: + """Copy key to clipboard using tkinter.""" + if not CLIPBOARD_AVAILABLE: + print("Clipboard functionality not available (tkinter not installed)") + print("Key:", key) + return + + root = tk.Tk() + root.withdraw() + root.clipboard_clear() + root.clipboard_append(key) + root.update() + print("Key copied to clipboard!") + root.destroy() + + def list_keys(self, search: str = "") -> None: + """List all keys with their aliases and metadata.""" + if not self.keys: + print("No keys available") + return + + print("\nAvailable SSH Keys:") + print("-" * 60) + + for i, key in enumerate(self.keys, 1): + name = self.get_key_name(key) + if search and search.lower() not in name.lower() and search.lower() not in self.aliases.get(name, {}).get('alias', '').lower(): + continue + + key_type = key.split()[0] + alias_info = self.aliases.get(name, {}) + alias = alias_info.get('alias', '') + expiry = alias_info.get('expiry', '') + + print(f"{i}. {name}") + if alias: + print(f" Alias: {alias}") + print(f" Type: {key_type}") + if expiry: + print(f" Expires: {expiry}") + print(f" Added: {alias_info.get('added', 'Unknown')}") + print("-" * 60) + + def set_expiry(self, key_idx: int, date_str: str) -> None: + """Set expiry date for a key.""" + if not (0 <= key_idx < len(self.keys)): + print("Invalid key index") + return + + name = self.get_key_name(self.keys[key_idx]) + if name not in self.aliases: + self.aliases[name] = {} + + self.aliases[name]['expiry'] = date_str + self.save_aliases() + print(f"Set expiry date {date_str} for key {name}") + + def get_system_keys(self) -> List[str]: + """Get all public SSH keys from the current system.""" + home = os.path.expanduser("~") + ssh_dir = os.path.join(home, ".ssh") + keys = [] + + if not os.path.exists(ssh_dir): + return keys + + for file in os.listdir(ssh_dir): + if file.endswith(".pub"): + try: + with open(os.path.join(ssh_dir, file), 'r') as f: + key = f.read().strip() + if key: + keys.append(key) + except: + continue + return keys + + def get_key_name(self, key: str) -> str: + """Extract name/comment from SSH key.""" + parts = key.strip().split() + return parts[-1] if len(parts) > 2 else "Unknown" + + def get_key_parts(self, key: str) -> tuple: + """Extract the key type and key data from an SSH key, ignoring comments.""" + parts = key.strip().split() + if len(parts) >= 2: + return (parts[0], parts[1]) # type and key data + return (None, None) + + def find_existing_key(self, new_key: str) -> Optional[int]: + """Find index of existing key with same type and data, ignoring comments.""" + new_type, new_data = self.get_key_parts(new_key) + if not new_type or not new_data: + return None + + for idx, existing_key in enumerate(self.keys): + existing_type, existing_data = self.get_key_parts(existing_key) + if existing_type == new_type and existing_data == new_data: + return idx + return None + + def sync_with_git(self, message: str) -> None: + """Sync changes with git repository.""" + try: + subprocess.run(["git", "add", self.keys_file], check=True) + subprocess.run(["git", "commit", "-m", message], check=True) + subprocess.run(["git", "pull", "--rebase"], check=True) + subprocess.run(["git", "push"], check=True) + print("Successfully synced with git repository") + except subprocess.CalledProcessError as e: + print(f"Error syncing with git: {e}") + + def capture_keys(self) -> None: + """Capture public keys from the current system.""" + system_keys = self.get_system_keys() + if not system_keys: + print("No SSH keys found on this system") + return + + print("\nFound the following SSH keys:") + for i, key in enumerate(system_keys, 1): + print(f"{i}. {self.get_key_name(key)}") + + selection = input("\nEnter numbers to add (comma-separated) or 'all': ").strip() + if not selection: + return + + added_keys = [] + if selection.lower() == 'all': + indices = range(len(system_keys)) + else: + try: + indices = [int(i.strip()) - 1 for i in selection.split(',')] + except ValueError: + print("Invalid selection") + return + + for idx in indices: + if 0 <= idx < len(system_keys): + key = system_keys[idx] + existing_idx = self.find_existing_key(key) + + if existing_idx is not None: + existing_name = self.get_key_name(self.keys[existing_idx]) + new_name = self.get_key_name(key) + print(f"\nKey {new_name} already exists as {existing_name}") + overwrite = input("Would you like to overwrite it? (y/N): ").lower() + + if overwrite != 'y': + print("Skipping key...") + continue + + # Remove the old key and its alias + old_name = self.get_key_name(self.keys[existing_idx]) + self.aliases.pop(old_name, None) + del self.keys[existing_idx] + + name = self.get_key_name(key) + alias = input(f"Enter alias for {name} (press Enter to skip): ").strip() + expiry = input("Enter expiry date (YYYY-MM-DD) or press Enter to skip: ").strip() + + metadata = { + 'added': datetime.now().strftime("%Y-%m-%d %H:%M:%S") + } + if alias: + metadata['alias'] = alias + if expiry: + metadata['expiry'] = expiry + + self.aliases[name] = metadata + self.keys.append(key) + added_keys.append(name) + + if added_keys: + self.save_keys() + if input("Would you like to sync changes? (y/N): ").lower() == 'y': + self.sync_with_git(f"Added keys: {', '.join(added_keys)}") + + def deploy_keys(self) -> None: + """Deploy selected keys to the current system.""" + if not self.keys: + print("No keys available to deploy") + return + + print("\nAvailable keys:") + for i, key in enumerate(self.keys, 1): + name = self.get_key_name(key) + alias = self.aliases.get(name, {}).get('alias', '') + print(f"{i}. {name} {f'({alias})' if alias else ''}") + + selection = input("\nEnter numbers to deploy (comma-separated) or 'all': ").strip() + if not selection: + return + + home = os.path.expanduser("~") + ssh_dir = os.path.join(home, ".ssh") + auth_keys_file = os.path.join(ssh_dir, "authorized_keys") + + try: + os.makedirs(ssh_dir, mode=0o700, exist_ok=True) + except OSError as e: + print(f"Error creating .ssh directory: {e}") + return + + if selection.lower() == 'all': + selected_keys = self.keys + else: + try: + indices = [int(i.strip()) - 1 for i in selection.split(',')] + selected_keys = [self.keys[i] for i in indices if 0 <= i < len(self.keys)] + except ValueError: + print("Invalid selection") + return + + existing_keys = set() + if os.path.exists(auth_keys_file): + try: + with open(auth_keys_file, 'r') as f: + existing_keys = set(line.strip() for line in f if line.strip()) + except OSError as e: + print(f"Error reading existing authorized_keys file: {e}") + return + + keys_to_add = [key for key in selected_keys if key not in existing_keys] + if not keys_to_add: + print("All selected keys are already deployed!") + return + + try: + with open(auth_keys_file, 'a') as f: + for key in keys_to_add: + f.write(key + '\n') + + # Set proper permissions + try: + os.chmod(auth_keys_file, 0o600) + except OSError as e: + print(f"Warning: Could not set permissions on {auth_keys_file}: {e}") + + # Verify deployment + try: + with open(auth_keys_file, 'r') as f: + deployed_keys = set(line.strip() for line in f if line.strip()) + + successfully_deployed = [] + failed_deployments = [] + + for key in keys_to_add: + name = self.get_key_name(key) + if key in deployed_keys: + successfully_deployed.append(name) + else: + failed_deployments.append(name) + + if successfully_deployed: + print("\nSuccessfully deployed keys:") + for name in successfully_deployed: + print(f"✓ {name}") + + if failed_deployments: + print("\nFailed to deploy keys:") + for name in failed_deployments: + print(f"✗ {name}") + + print(f"\nDeployment location: {auth_keys_file}") + if successfully_deployed: + print(f"Permissions: {oct(os.stat(auth_keys_file).st_mode)[-3:]}") + + except OSError as e: + print(f"Error verifying deployment: {e}") + + except OSError as e: + print(f"Error writing to authorized_keys file: {e}") + return + + def set_alias(self) -> None: + """Set alias for existing keys.""" + if not self.keys: + print("No keys available") + return + + print("\nAvailable keys:") + for i, key in enumerate(self.keys, 1): + name = self.get_key_name(key) + alias = self.aliases.get(name, '') + print(f"{i}. {name} {f'({alias})' if alias else ''}") + + selection = input("\nEnter key number to set alias: ").strip() + try: + idx = int(selection) - 1 + if 0 <= idx < len(self.keys): + name = self.get_key_name(self.keys[idx]) + alias = input(f"Enter new alias for {name}: ").strip() + if alias: + self.aliases[name] = alias + if input("Would you like to sync changes? (y/N): ").lower() == 'y': + self.sync_with_git(f"Updated alias for {name}: {alias}") + except ValueError: + print("Invalid selection") + + def delete_keys(self) -> None: + """Delete selected keys.""" + if not self.keys: + print("No keys available to delete") + return + + print("\nAvailable keys:") + for i, key in enumerate(self.keys, 1): + name = self.get_key_name(key) + alias = self.aliases.get(name, {}).get('alias', '') + print(f"{i}. {name} {f'({alias})' if alias else ''}") + + selection = input("\nEnter numbers to delete (comma-separated) or 'all': ").strip() + if not selection: + return + + # Get list of keys to delete first + to_delete = [] + if selection.lower() == 'all': + print("\nYou are about to delete ALL keys:") + for key in self.keys: + name = self.get_key_name(key) + alias = self.aliases.get(name, {}).get('alias', '') + print(f"- {name} {f'({alias})' if alias else ''}") + + confirm = input("\nAre you sure you want to delete all keys? This cannot be undone. (yes/N): ").lower() + if confirm == 'yes': + to_delete = list(range(len(self.keys))) + else: + print("Operation cancelled.") + return + else: + try: + indices = sorted([int(i.strip()) - 1 for i in selection.split(',')], reverse=True) + print("\nYou are about to delete these keys:") + for idx in indices: + if 0 <= idx < len(self.keys): + name = self.get_key_name(self.keys[idx]) + alias = self.aliases.get(name, {}).get('alias', '') + print(f"- {name} {f'({alias})' if alias else ''}") + + confirm = input("\nAre you sure you want to delete these keys? This cannot be undone. (yes/N): ").lower() + if confirm == 'yes': + to_delete = indices + else: + print("Operation cancelled.") + return + except ValueError: + print("Invalid selection") + return + + # Now perform the deletion + deleted_keys = [] + for idx in to_delete: + if 0 <= idx < len(self.keys): + name = self.get_key_name(self.keys[idx]) + deleted_keys.append(name) + self.aliases.pop(name, None) + del self.keys[idx] + + if deleted_keys: + self.save_keys() + print(f"\nSuccessfully deleted {len(deleted_keys)} key(s):") + for name in deleted_keys: + print(f"- {name}") + if input("\nWould you like to sync changes? (y/N): ").lower() == 'y': + self.sync_with_git(f"Deleted keys: {', '.join(deleted_keys)}") + + def get_system_key_files(self) -> List[tuple]: + """Get all public key files from the current system with their paths.""" + home = os.path.expanduser("~") + ssh_dir = os.path.join(home, ".ssh") + key_files = [] + + if not os.path.exists(ssh_dir): + return key_files + + for file in os.listdir(ssh_dir): + if file.endswith(".pub"): + try: + path = os.path.join(ssh_dir, file) + with open(path, 'r') as f: + content = f.read().strip() + if content: + key_files.append((file, path, content)) + except: + continue + return sorted(key_files) + + def manage_system_keys(self) -> None: + """Manage SSH keys on the current system.""" + while True: + key_files = self.get_system_key_files() + if not key_files: + print("No SSH keys found on this system") + return + + print("\nSystem SSH Keys:") + print("-" * 60) + for i, (filename, path, content) in enumerate(key_files, 1): + name = self.get_key_name(content) + print(f"{i}. {filename}") + print(f" Name: {name}") + print(f" Path: {path}") + if os.path.exists(path[:-4]): # Check if private key exists (remove .pub) + print(" Private key: Yes") + print("-" * 60) + + print("\nSystem Key Management:") + print("1. Rename key file") + print("2. Delete key file") + print("3. Copy public key to clipboard") + print("4. Back to main menu") + + choice = input("\nEnter your choice (1-4): ").strip() + + if choice == '1': + key_num = input("Enter key number to rename: ").strip() + try: + idx = int(key_num) - 1 + if 0 <= idx < len(key_files): + old_file, old_path, _ = key_files[idx] + new_name = input(f"Enter new name for {old_file} (without .pub): ").strip() + if not new_name: + print("Invalid name") + continue + + new_file = f"{new_name}.pub" + new_path = os.path.join(os.path.dirname(old_path), new_file) + + if os.path.exists(new_path): + print(f"Error: {new_file} already exists") + continue + + # Rename both public and private key if it exists + try: + os.rename(old_path, new_path) + print(f"Renamed {old_file} to {new_file}") + + # Try to rename private key if it exists + old_private = old_path[:-4] # remove .pub + new_private = new_path[:-4] + if os.path.exists(old_private): + os.rename(old_private, new_private) + print(f"Renamed private key {os.path.basename(old_private)} to {os.path.basename(new_private)}") + except OSError as e: + print(f"Error renaming key: {e}") + except ValueError: + print("Invalid selection") + + elif choice == '2': + key_num = input("Enter key number to delete: ").strip() + try: + idx = int(key_num) - 1 + if 0 <= idx < len(key_files): + filename, path, _ = key_files[idx] + print(f"\nYou are about to delete: {filename}") + if os.path.exists(path[:-4]): + print("Warning: This will also delete the private key!") + + confirm = input("\nAre you sure you want to delete this key? This cannot be undone. (yes/N): ").lower() + if confirm == 'yes': + try: + os.remove(path) + print(f"Deleted {filename}") + + # Try to delete private key if it exists + private_key = path[:-4] + if os.path.exists(private_key): + os.remove(private_key) + print(f"Deleted private key {os.path.basename(private_key)}") + except OSError as e: + print(f"Error deleting key: {e}") + else: + print("Operation cancelled.") + except ValueError: + print("Invalid selection") + + elif choice == '3': + key_num = input("Enter key number to copy: ").strip() + try: + idx = int(key_num) - 1 + if 0 <= idx < len(key_files): + _, _, content = key_files[idx] + self.copy_to_clipboard(content) + except ValueError: + print("Invalid selection") + + elif choice == '4': + break + else: + print("Invalid choice") + + def check_repo_visibility(self) -> bool: + """Check if the git repository is public. + Returns True if public, False if private or unable to determine.""" + try: + # Get the remote URL + result = subprocess.run( + ["git", "config", "--get", "remote.origin.url"], + capture_output=True, + text=True, + check=True + ) + remote_url = result.stdout.strip() + + # Handle different git URL formats + if remote_url.startswith('git@github.com:'): + # Convert SSH URL to HTTPS + repo_path = remote_url.split('git@github.com:')[1].replace('.git', '') + api_url = f'https://api.github.com/repos/{repo_path}' + elif remote_url.startswith('https://github.com/'): + repo_path = remote_url.split('https://github.com/')[1].replace('.git', '') + api_url = f'https://api.github.com/repos/{repo_path}' + else: + return False # Non-GitHub repository, assume private + + # Try to access the repository anonymously + result = subprocess.run( + ["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", api_url], + capture_output=True, + text=True + ) + + # If we can access it anonymously, it's public + return result.stdout.strip() == "200" + + except subprocess.CalledProcessError: + return False # Assume private if we can't determine + +def main(): + manager = SSHKeyManager() + + while True: + print("\nSSH Public Key Manager") + print("1. Capture public key of current device") + print("2. Deploy public keys to current device") + print("3. Set alias for key") + print("4. Sync with git") + print("5. Delete keys") + print("6. List all keys") + print("7. Search keys") + print("8. Copy key to clipboard") + print("9. Set key expiry") + print("10. Manage system keys") + print("11. Exit") + + choice = input("\nEnter your choice (1-11): ").strip() + + if choice == '1': + manager.capture_keys() + elif choice == '2': + manager.deploy_keys() + elif choice == '3': + manager.set_alias() + elif choice == '4': + manager.sync_with_git("Manual sync") + elif choice == '5': + manager.delete_keys() + elif choice == '6': + manager.list_keys() + elif choice == '7': + search = input("Enter search term: ").strip() + manager.list_keys(search) + elif choice == '8': + manager.list_keys() + key_num = input("\nEnter key number to copy: ").strip() + try: + idx = int(key_num) - 1 + if 0 <= idx < len(manager.keys): + manager.copy_to_clipboard(manager.keys[idx]) + except ValueError: + print("Invalid key number") + elif choice == '9': + manager.list_keys() + key_num = input("\nEnter key number to set expiry: ").strip() + expiry = input("Enter expiry date (YYYY-MM-DD): ").strip() + try: + idx = int(key_num) - 1 + manager.set_expiry(idx, expiry) + except ValueError: + print("Invalid key number") + elif choice == '10': + manager.manage_system_keys() + elif choice == '11': + break + else: + print("Invalid choice") + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + print("\nExiting...") + sys.exit(0) \ No newline at end of file