Initial commit

This commit is contained in:
Chris Sewell 2025-02-07 14:56:23 -05:00
commit 66d1f7f28d
2 changed files with 802 additions and 0 deletions

128
README.md Normal file
View 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
View 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)