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:
parent
68ff4238e0
commit
2533b56549
31
README.md
31
README.md
@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
@ -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**
|
||||||
|
@ -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())
|
||||||
|
@ -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())
|
||||||
|
@ -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
|
||||||
|
@ -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())
|
||||||
|
@ -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()
|
||||||
|
@ -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()
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user