diff --git a/README.md b/README.md index 4f989f7..c8abffa 100644 --- a/README.md +++ b/README.md @@ -29,8 +29,8 @@ Expected structure ├── tisbackup-config.ini <-- backups config └── tisbackup_gui.ini <-- tisbackup config └─ssh/ - ├── id_rsa <-- SSH Key - └── id_rsa.pub <-- SSH PubKey + ├── id_ed25519 <-- SSH Private Key (Ed25519 recommended) + └── id_ed25519.pub <-- SSH Public Key compose.yaml ``` @@ -72,8 +72,30 @@ services: ## Configuration - * Provide an SSH key and store it in `./ssh` +### SSH Keys + + * **Generate SSH keys** (Ed25519 recommended): + ```bash + # Ed25519 (most secure, recommended) + ssh-keygen -t ed25519 -f ./ssh/id_ed25519 -C "tisbackup@yourserver" + + # Or ECDSA (also secure) + ssh-keygen -t ecdsa -b 521 -f ./ssh/id_ecdsa -C "tisbackup@yourserver" + + # Or RSA (legacy, minimum 2048 bits) + ssh-keygen -t rsa -b 4096 -f ./ssh/id_rsa -C "tisbackup@yourserver" + ``` + **⚠️ Note:** DSA keys are no longer supported due to security vulnerabilities + + * Copy public key to remote servers: + ```bash + ssh-copy-id -i ./ssh/id_ed25519.pub root@remote-server + ``` + +### Configuration Files + * Setup config files in the `./config` directory + * **SECURITY**: Generate and set a secure Flask secret key: ```bash # Generate a secure random secret key @@ -99,7 +121,8 @@ server_name=srvads.poudlard.lan remote_dir=/var/lib/samba/ compression=True ;exclude_list="/proc/**","/sys/**","/dev/**" -private_key=/config_ssh/id_rsa +# Use Ed25519 key (recommended), or ECDSA/RSA (DSA not supported) +private_key=/config_ssh/id_ed25519 ssh_port = 22 ``` diff --git a/SECURITY_IMPROVEMENTS.md b/SECURITY_IMPROVEMENTS.md index 78fadd3..1fd2118 100644 --- a/SECURITY_IMPROVEMENTS.md +++ b/SECURITY_IMPROVEMENTS.md @@ -149,6 +149,83 @@ export TISBACKUP_SECRET_KEY=your-generated-key-here **Security Impact:** Eliminates hardcoded secret in source code, prevents session hijacking and CSRF attacks +### 8. Modernized SSH Key Algorithm Support +**Files Modified:** [libtisbackup/common.py](libtisbackup/common.py#L140), all backup drivers, [README.md](README.md) + +**Before:** +```python +try: + mykey = paramiko.RSAKey.from_private_key_file(self.private_key) +except paramiko.SSHException: + mykey = paramiko.DSSKey.from_private_key_file(self.private_key) +``` + +**After:** +```python +def load_ssh_private_key(private_key_path): + """Load SSH private key with modern algorithm support. + + Tries to load the key in order of preference: + 1. Ed25519 (most secure, modern) + 2. ECDSA (secure, widely supported) + 3. RSA (legacy, still secure with sufficient key size) + + DSA is not supported as it's deprecated and insecure. + """ + key_types = [ + ("Ed25519", paramiko.Ed25519Key), + ("ECDSA", paramiko.ECDSAKey), + ("RSA", paramiko.RSAKey), + ] + + for key_name, key_class in key_types: + try: + return key_class.from_private_key_file(private_key_path) + except paramiko.SSHException: + continue + + raise paramiko.SSHException( + f"Unable to load private key. " + f"Supported formats: Ed25519 (recommended), ECDSA, RSA. " + f"DSA keys are no longer supported." + ) +``` + +**Changes:** +- Created centralized `load_ssh_private_key()` helper function +- Updated all SSH key loading locations across codebase: + - [common.py](libtisbackup/common.py): `do_preexec`, `do_postexec`, `run_remote_command` + - [backup_mysql.py](libtisbackup/backup_mysql.py) + - [backup_pgsql.py](libtisbackup/backup_pgsql.py) + - [backup_sqlserver.py](libtisbackup/backup_sqlserver.py) + - [backup_oracle.py](libtisbackup/backup_oracle.py) + - [backup_samba4.py](libtisbackup/backup_samba4.py) +- Removed deprecated DSA key support +- Added Ed25519 as preferred algorithm +- Added ECDSA as second choice +- RSA remains supported for compatibility +- Clear error message indicating DSA is no longer supported +- Updated README.md with key generation instructions + +**SSH Key Generation:** +```bash +# Ed25519 (recommended) +ssh-keygen -t ed25519 -f ./ssh/id_ed25519 -C "tisbackup" + +# ECDSA (also secure) +ssh-keygen -t ecdsa -b 521 -f ./ssh/id_ecdsa + +# RSA (legacy, minimum 4096 bits) +ssh-keygen -t rsa -b 4096 -f ./ssh/id_rsa +``` + +**Security Impact:** +- Eliminates support for vulnerable DSA algorithm (1024-bit limit, FIPS deprecated) +- Prioritizes Ed25519 (fast, secure, resistant to timing attacks) +- Supports ECDSA as secure alternative +- Maintains RSA compatibility for legacy systems +- Clear migration path for users with old keys + ## Remaining Security Issues (Critical - Not Fixed) ### 1. **No Authentication on Flask Routes** diff --git a/libtisbackup/backup_mysql.py b/libtisbackup/backup_mysql.py index d8afe47..185a8f6 100644 --- a/libtisbackup/backup_mysql.py +++ b/libtisbackup/backup_mysql.py @@ -58,11 +58,7 @@ class backup_mysql(backup_generic): raise Exception("backup destination directory already exists : %s" % self.dest_dir) self.logger.debug("[%s] Connecting to %s with user root and key %s", self.backup_name, self.server_name, self.private_key) - try: - mykey = paramiko.RSAKey.from_private_key_file(self.private_key) - except paramiko.SSHException: - # mykey = paramiko.DSSKey.from_private_key_file(self.private_key) - mykey = paramiko.Ed25519Key.from_private_key_file(self.private_key) + mykey = load_ssh_private_key(self.private_key) self.ssh = paramiko.SSHClient() self.ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) diff --git a/libtisbackup/backup_oracle.py b/libtisbackup/backup_oracle.py index 4db8696..fcfe086 100644 --- a/libtisbackup/backup_oracle.py +++ b/libtisbackup/backup_oracle.py @@ -51,11 +51,7 @@ class backup_oracle(backup_generic): self.logger.debug( "[%s] Connecting to %s with user %s and key %s", self.backup_name, self.server_name, self.username, self.private_key ) - try: - mykey = paramiko.RSAKey.from_private_key_file(self.private_key) - except paramiko.SSHException: - # mykey = paramiko.DSSKey.from_private_key_file(self.private_key) - mykey = paramiko.Ed25519Key.from_private_key_file(self.private_key) + mykey = load_ssh_private_key(self.private_key) self.ssh = paramiko.SSHClient() self.ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) diff --git a/libtisbackup/backup_pgsql.py b/libtisbackup/backup_pgsql.py index 6820ffa..c0a03cf 100644 --- a/libtisbackup/backup_pgsql.py +++ b/libtisbackup/backup_pgsql.py @@ -53,11 +53,7 @@ class backup_pgsql(backup_generic): else: raise Exception("backup destination directory already exists : %s" % self.dest_dir) - try: - mykey = paramiko.RSAKey.from_private_key_file(self.private_key) - except paramiko.SSHException: - # mykey = paramiko.DSSKey.from_private_key_file(self.private_key) - mykey = paramiko.Ed25519Key.from_private_key_file(self.private_key) + mykey = load_ssh_private_key(self.private_key) self.logger.debug( '[%s] Trying to connect to "%s" with username root and key "%s"', self.backup_name, self.server_name, self.private_key diff --git a/libtisbackup/backup_samba4.py b/libtisbackup/backup_samba4.py index d1a64b6..a628c05 100644 --- a/libtisbackup/backup_samba4.py +++ b/libtisbackup/backup_samba4.py @@ -54,11 +54,7 @@ class backup_samba4(backup_generic): raise Exception("backup destination directory already exists : %s" % self.dest_dir) self.logger.debug("[%s] Connecting to %s with user root and key %s", self.backup_name, self.server_name, self.private_key) - try: - mykey = paramiko.RSAKey.from_private_key_file(self.private_key) - except paramiko.SSHException: - # mykey = paramiko.DSSKey.from_private_key_file(self.private_key) - mykey = paramiko.Ed25519Key.from_private_key_file(self.private_key) + mykey = load_ssh_private_key(self.private_key) self.ssh = paramiko.SSHClient() self.ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) diff --git a/libtisbackup/backup_sqlserver.py b/libtisbackup/backup_sqlserver.py index 7208c74..998557d 100644 --- a/libtisbackup/backup_sqlserver.py +++ b/libtisbackup/backup_sqlserver.py @@ -53,11 +53,7 @@ class backup_sqlserver(backup_generic): db_server_name = "localhost" def do_backup(self, stats): - try: - mykey = paramiko.RSAKey.from_private_key_file(self.private_key) - except paramiko.SSHException: - # mykey = paramiko.DSSKey.from_private_key_file(self.private_key) - mykey = paramiko.Ed25519Key.from_private_key_file(self.private_key) + mykey = load_ssh_private_key(self.private_key) self.logger.debug("[%s] Connecting to %s with user root and key %s", self.backup_name, self.server_name, self.private_key) ssh = paramiko.SSHClient() diff --git a/libtisbackup/common.py b/libtisbackup/common.py index 6fd40f3..22a4a66 100644 --- a/libtisbackup/common.py +++ b/libtisbackup/common.py @@ -137,6 +137,48 @@ def check_string(test_string): print(("Invalid : %r" % (test_string,))) +def load_ssh_private_key(private_key_path): + """Load SSH private key with modern algorithm support. + + Tries to load the key in order of preference: + 1. Ed25519 (most secure, modern) + 2. ECDSA (secure, widely supported) + 3. RSA (legacy, still secure with sufficient key size) + + DSA is not supported as it's deprecated and insecure. + + Args: + private_key_path: Path to the private key file + + Returns: + paramiko key object + + Raises: + paramiko.SSHException: If key cannot be loaded + """ + key_types = [ + ("Ed25519", paramiko.Ed25519Key), + ("ECDSA", paramiko.ECDSAKey), + ("RSA", paramiko.RSAKey), + ] + + last_exception = None + for key_name, key_class in key_types: + try: + return key_class.from_private_key_file(private_key_path) + except paramiko.SSHException as e: + last_exception = e + continue + + # If we get here, none of the key types worked + raise paramiko.SSHException( + f"Unable to load private key from {private_key_path}. " + f"Supported formats: Ed25519 (recommended), ECDSA, RSA. " + f"DSA keys are no longer supported. " + f"Last error: {last_exception}" + ) + + def convert_bytes(bytes): if bytes is None: return None @@ -537,11 +579,7 @@ def ssh_exec(command, ssh=None, server_name="", remote_user="", private_key="", """ if not ssh: assert server_name and remote_user and private_key - try: - mykey = paramiko.RSAKey.from_private_key_file(private_key) - except paramiko.SSHException: - # mykey = paramiko.DSSKey.from_private_key_file(private_key) - mykey = paramiko.Ed25519Key.from_private_key_file(private_key) + mykey = load_ssh_private_key(private_key) ssh = paramiko.SSHClient() ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) @@ -640,14 +678,11 @@ class backup_generic(ABC): def do_preexec(self, stats): self.logger.info("[%s] executing preexec %s ", self.backup_name, self.preexec) - try: - mykey = paramiko.RSAKey.from_private_key_file(self.private_key) - except paramiko.SSHException: - mykey = paramiko.DSSKey.from_private_key_file(self.private_key) + mykey = load_ssh_private_key(self.private_key) ssh = paramiko.SSHClient() ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) - ssh.connect(self.server_name, username=self.remote_user, pkey=mykey) + ssh.connect(self.server_name, username=self.remote_user, pkey=mykey, port=self.ssh_port) tran = ssh.get_transport() chan = tran.open_session() @@ -666,14 +701,11 @@ class backup_generic(ABC): def do_postexec(self, stats): self.logger.info("[%s] executing postexec %s ", self.backup_name, self.postexec) - try: - mykey = paramiko.RSAKey.from_private_key_file(self.private_key) - except paramiko.SSHException: - mykey = paramiko.DSSKey.from_private_key_file(self.private_key) + mykey = load_ssh_private_key(self.private_key) ssh = paramiko.SSHClient() ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) - ssh.connect(self.server_name, username=self.remote_user, pkey=mykey) + ssh.connect(self.server_name, username=self.remote_user, pkey=mykey, port=self.ssh_port) tran = ssh.get_transport() chan = tran.open_session()