Initial commit
This commit is contained in:
commit
66d1f7f28d
128
README.md
Normal file
128
README.md
Normal file
@ -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 <your-private-repository-url>
|
||||||
|
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!
|
674
manage_keys.py
Executable file
674
manage_keys.py
Executable file
@ -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)
|
Loading…
x
Reference in New Issue
Block a user