feat(security): modernize SSH key algorithm support with Ed25519

Replace deprecated DSA key support with modern SSH key algorithms,
prioritizing Ed25519 as the most secure option.

Changes:
- Add load_ssh_private_key() helper function in common.py
- Support Ed25519 (preferred), ECDSA, and RSA key types
- Remove deprecated and insecure DSA key support
- Update all SSH key loading across backup drivers:
  * common.py: do_preexec, do_postexec, run_remote_command
  * backup_mysql.py
  * backup_pgsql.py
  * backup_sqlserver.py
  * backup_oracle.py
  * backup_samba4.py
- Add ssh_port parameter to preexec/postexec connections
- Update README.md with SSH key generation instructions
- Document supported algorithms and migration path

Algorithm priority:
1. Ed25519 (most secure, modern, fast, timing-attack resistant)
2. ECDSA (secure, widely supported)
3. RSA (legacy support, requires 2048+ bits)

Security improvements:
- Eliminates vulnerable DSA algorithm (1024-bit limit, FIPS deprecated)
- Prioritizes elliptic curve cryptography (Ed25519, ECDSA)
- Provides clear error messages for unsupported key types
- Maintains backward compatibility with existing RSA keys

Documentation:
- Add SSH key generation examples to README.md
- Update expected directory structure to show Ed25519 keys
- Add migration notes in SECURITY_IMPROVEMENTS.md
- Include key generation commands for all supported types

Breaking change:
- DSA keys are no longer supported and will fail with clear error message
- Users must migrate to Ed25519, ECDSA, or RSA (4096-bit recommended)

Migration:
```bash
# Generate new Ed25519 key
ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519

# Copy to remote servers
ssh-copy-id -i ~/.ssh/id_ed25519.pub user@remote
```

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
k3nny 2025-10-05 01:39:17 +02:00
parent 68ff4238e0
commit 2533b56549
8 changed files with 156 additions and 44 deletions

View File

@ -29,8 +29,8 @@ Expected structure
├── tisbackup-config.ini <-- backups config ├── tisbackup-config.ini <-- backups config
└── tisbackup_gui.ini <-- tisbackup config └── tisbackup_gui.ini <-- tisbackup config
└─ssh/ └─ssh/
├── id_rsa <-- SSH Key ├── id_ed25519 <-- SSH Private Key (Ed25519 recommended)
└── id_rsa.pub <-- SSH PubKey └── id_ed25519.pub <-- SSH Public Key
compose.yaml compose.yaml
``` ```
@ -72,8 +72,30 @@ services:
## Configuration ## 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 * Setup config files in the `./config` directory
* **SECURITY**: Generate and set a secure Flask secret key: * **SECURITY**: Generate and set a secure Flask secret key:
```bash ```bash
# Generate a secure random secret key # Generate a secure random secret key
@ -99,7 +121,8 @@ server_name=srvads.poudlard.lan
remote_dir=/var/lib/samba/ remote_dir=/var/lib/samba/
compression=True compression=True
;exclude_list="/proc/**","/sys/**","/dev/**" ;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 ssh_port = 22
``` ```

View File

@ -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 **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) ## Remaining Security Issues (Critical - Not Fixed)
### 1. **No Authentication on Flask Routes** ### 1. **No Authentication on Flask Routes**

View File

@ -58,11 +58,7 @@ class backup_mysql(backup_generic):
raise Exception("backup destination directory already exists : %s" % self.dest_dir) 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) self.logger.debug("[%s] Connecting to %s with user root and key %s", self.backup_name, self.server_name, self.private_key)
try: mykey = load_ssh_private_key(self.private_key)
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)
self.ssh = paramiko.SSHClient() self.ssh = paramiko.SSHClient()
self.ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) self.ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())

View File

@ -51,11 +51,7 @@ class backup_oracle(backup_generic):
self.logger.debug( self.logger.debug(
"[%s] Connecting to %s with user %s and key %s", self.backup_name, self.server_name, self.username, self.private_key "[%s] Connecting to %s with user %s and key %s", self.backup_name, self.server_name, self.username, self.private_key
) )
try: mykey = load_ssh_private_key(self.private_key)
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)
self.ssh = paramiko.SSHClient() self.ssh = paramiko.SSHClient()
self.ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) self.ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())

View File

@ -53,11 +53,7 @@ class backup_pgsql(backup_generic):
else: else:
raise Exception("backup destination directory already exists : %s" % self.dest_dir) raise Exception("backup destination directory already exists : %s" % self.dest_dir)
try: mykey = load_ssh_private_key(self.private_key)
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)
self.logger.debug( self.logger.debug(
'[%s] Trying to connect to "%s" with username root and key "%s"', self.backup_name, self.server_name, self.private_key '[%s] Trying to connect to "%s" with username root and key "%s"', self.backup_name, self.server_name, self.private_key

View File

@ -54,11 +54,7 @@ class backup_samba4(backup_generic):
raise Exception("backup destination directory already exists : %s" % self.dest_dir) 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) self.logger.debug("[%s] Connecting to %s with user root and key %s", self.backup_name, self.server_name, self.private_key)
try: mykey = load_ssh_private_key(self.private_key)
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)
self.ssh = paramiko.SSHClient() self.ssh = paramiko.SSHClient()
self.ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) self.ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())

View File

@ -53,11 +53,7 @@ class backup_sqlserver(backup_generic):
db_server_name = "localhost" db_server_name = "localhost"
def do_backup(self, stats): def do_backup(self, stats):
try: mykey = load_ssh_private_key(self.private_key)
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)
self.logger.debug("[%s] Connecting to %s with user root and key %s", self.backup_name, self.server_name, 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() ssh = paramiko.SSHClient()

View File

@ -137,6 +137,48 @@ def check_string(test_string):
print(("Invalid : %r" % (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): def convert_bytes(bytes):
if bytes is None: if bytes is None:
return None return None
@ -537,11 +579,7 @@ def ssh_exec(command, ssh=None, server_name="", remote_user="", private_key="",
""" """
if not ssh: if not ssh:
assert server_name and remote_user and private_key assert server_name and remote_user and private_key
try: mykey = load_ssh_private_key(private_key)
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)
ssh = paramiko.SSHClient() ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
@ -640,14 +678,11 @@ class backup_generic(ABC):
def do_preexec(self, stats): def do_preexec(self, stats):
self.logger.info("[%s] executing preexec %s ", self.backup_name, self.preexec) self.logger.info("[%s] executing preexec %s ", self.backup_name, self.preexec)
try: mykey = load_ssh_private_key(self.private_key)
mykey = paramiko.RSAKey.from_private_key_file(self.private_key)
except paramiko.SSHException:
mykey = paramiko.DSSKey.from_private_key_file(self.private_key)
ssh = paramiko.SSHClient() ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) 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() tran = ssh.get_transport()
chan = tran.open_session() chan = tran.open_session()
@ -666,14 +701,11 @@ class backup_generic(ABC):
def do_postexec(self, stats): def do_postexec(self, stats):
self.logger.info("[%s] executing postexec %s ", self.backup_name, self.postexec) self.logger.info("[%s] executing postexec %s ", self.backup_name, self.postexec)
try: mykey = load_ssh_private_key(self.private_key)
mykey = paramiko.RSAKey.from_private_key_file(self.private_key)
except paramiko.SSHException:
mykey = paramiko.DSSKey.from_private_key_file(self.private_key)
ssh = paramiko.SSHClient() ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) 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() tran = ssh.get_transport()
chan = tran.open_session() chan = tran.open_session()