Skip to content

Commit e78802d

Browse files
committed
MAVlink msg signing updated with recommended improvements
Signed-off-by: PritamP20 <pripritam7@gmail.com>
1 parent 46e8d66 commit e78802d

10 files changed

+487
-282
lines changed

INSTALL.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,50 @@ You might need to also do:
116116
brew install uv python-tk@3.9
117117
```
118118

119+
## OS Keyring Setup (Optional - For MAVLink Signing)
120+
121+
If you plan to use [MAVLink 2.0 message signing](https://mavlink.io/en/guide/message_signing.html),
122+
the application can securely store signing keys using your operating system's keyring.
123+
124+
### Windows
125+
126+
**No setup required** - Uses Windows Credential Manager (built-in).
127+
128+
### macOS
129+
130+
**No setup required** - Uses Keychain (built-in).
131+
132+
### Linux
133+
134+
Most desktop environments have keyring support pre-installed:
135+
136+
- **GNOME**: Uses GNOME Keyring / Secret Service
137+
- **KDE**: Uses KWallet
138+
- **Headless/Server**: Falls back to encrypted file storage (no keyring)
139+
140+
**If keyring is not installed**, install it for your distribution:
141+
142+
```bash
143+
# Ubuntu/Debian (GNOME)
144+
sudo apt install gnome-keyring libsecret-1-0
145+
146+
# Fedora (GNOME)
147+
sudo dnf install gnome-keyring libsecret
148+
149+
# Arch Linux
150+
sudo pacman -S gnome-keyring libsecret
151+
```
152+
153+
### Verifying Keyring Availability
154+
155+
```python
156+
from ardupilot_methodic_configurator.backend_signing_keystore import SigningKeystore
157+
keystore = SigningKeystore()
158+
print(f"Keyring available: {keystore.keyring_available}")
159+
```
160+
161+
If keyring is not available, the application automatically falls back to encrypted file storage.
162+
119163
## Install *Mission Planner* software on a PC or Mac
120164

121165
1. Download and install [Mission Planner](https://firmware.ardupilot.org/Tools/MissionPlanner/).

ardupilot_methodic_configurator/backend_flightcontroller.py

Lines changed: 36 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -486,7 +486,7 @@ def setup_signing( # pylint: disable=too-many-arguments, too-many-positional-ar
486486
allow_unsigned_in: bool = True,
487487
initial_timestamp: int = 0,
488488
link_id: int = 0,
489-
) -> tuple[bool, str]:
489+
) -> bool:
490490
"""
491491
Set up MAVLink 2.0 message signing for secure communication.
492492
@@ -501,12 +501,13 @@ def setup_signing( # pylint: disable=too-many-arguments, too-many-positional-ar
501501
link_id: Link ID for signing (0-255, default: 0)
502502
503503
Returns:
504-
tuple[bool, str]: (success, error_message)
505-
- success is True if signing was set up successfully
506-
- error_message is empty on success or contains error description
504+
bool: True if signing was set up successfully
507505
508506
Raises:
507+
ConnectionError: If no flight controller connection is available
509508
ValueError: If key is not 32 bytes or link_id is out of range
509+
NotImplementedError: If pymavlink version doesn't support signing
510+
RuntimeError: If signing setup fails for other reasons
510511
511512
Note:
512513
MAVLink signing provides authentication (not encryption).
@@ -515,9 +516,9 @@ def setup_signing( # pylint: disable=too-many-arguments, too-many-positional-ar
515516
516517
"""
517518
if self.master is None:
518-
error_msg = _("No flight controller connection available for signing setup")
519-
logging_warning(error_msg)
520-
return False, error_msg
519+
msg = _("No flight controller connection available for signing setup")
520+
logging_warning(msg)
521+
raise ConnectionError(msg)
521522

522523
if len(key) != 32:
523524
msg = f"Signing key must be 32 bytes, got {len(key)} bytes"
@@ -543,16 +544,16 @@ def setup_signing( # pylint: disable=too-many-arguments, too-many-positional-ar
543544
_("MAVLink signing configured: sign_outgoing=%(sign)s, allow_unsigned=%(unsigned)s"),
544545
{"sign": sign_outgoing, "unsigned": allow_unsigned_in},
545546
)
546-
return True, ""
547+
return True
547548

548-
except AttributeError:
549-
error_msg = _("MAVLink signing not supported by this pymavlink version")
550-
logging_error(error_msg)
551-
return False, error_msg
552-
except Exception as exc: # pylint: disable=broad-except
553-
error_msg = _("Failed to set up MAVLink signing: %(error)s") % {"error": str(exc)}
554-
logging_error(error_msg)
555-
return False, error_msg
549+
except AttributeError as exc:
550+
msg = _("MAVLink signing not supported by this pymavlink version")
551+
logging_error(msg)
552+
raise NotImplementedError(msg) from exc
553+
except Exception as exc:
554+
msg = _("Failed to set up MAVLink signing: %(error)s") % {"error": str(exc)}
555+
logging_error(msg)
556+
raise RuntimeError(msg) from exc
556557

557558
def _unsigned_callback(self, msg: object) -> bool:
558559
"""
@@ -581,37 +582,40 @@ def _unsigned_callback(self, msg: object) -> bool:
581582
# This allows communication during signing setup and transition periods
582583
return True
583584

584-
def disable_signing(self) -> tuple[bool, str]:
585+
def disable_signing(self) -> bool:
585586
"""
586587
Disable MAVLink message signing.
587588
588589
This removes signing configuration and returns to unsigned communication.
589590
590591
Returns:
591-
tuple[bool, str]: (success, error_message)
592-
- success is True if signing was disabled successfully
593-
- error_message is empty on success or contains error description
592+
bool: True if signing was disabled successfully
593+
594+
Raises:
595+
ConnectionError: If no flight controller connection is available
596+
NotImplementedError: If pymavlink version doesn't support signing
597+
RuntimeError: If disabling signing fails for other reasons
594598
595599
"""
596600
if self.master is None:
597-
error_msg = _("No flight controller connection available")
598-
logging_warning(error_msg)
599-
return False, error_msg
601+
msg = _("No flight controller connection available")
602+
logging_warning(msg)
603+
raise ConnectionError(msg)
600604

601605
try:
602606
# Disable signing by passing None as key
603607
# Type ignore needed because MavlinkConnection is a Union including object fallback
604608
self.master.setup_signing(None, sign_outgoing=False, allow_unsigned_callback=None) # type: ignore[union-attr]
605609
logging_info(_("MAVLink signing disabled"))
606-
return True, ""
607-
except AttributeError:
608-
error_msg = _("MAVLink signing not supported by this pymavlink version")
609-
logging_error(error_msg)
610-
return False, error_msg
611-
except Exception as exc: # pylint: disable=broad-except
612-
error_msg = _("Failed to disable MAVLink signing: %(error)s") % {"error": str(exc)}
613-
logging_error(error_msg)
614-
return False, error_msg
610+
return True
611+
except AttributeError as exc:
612+
msg = _("MAVLink signing not supported by this pymavlink version")
613+
logging_error(msg)
614+
raise NotImplementedError(msg) from exc
615+
except Exception as exc:
616+
msg = _("Failed to disable MAVLink signing: %(error)s") % {"error": str(exc)}
617+
logging_error(msg)
618+
raise RuntimeError(msg) from exc
615619

616620
def get_signing_status(self) -> dict[str, object]:
617621
"""
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
"""
2+
MAVLink 2.0 signing configuration persistence backend.
3+
4+
This file is part of ArduPilot Methodic Configurator. https://github.com/ArduPilot/MethodicConfigurator
5+
6+
SPDX-FileCopyrightText: 2024-2025 Amilcar do Carmo Lucas <amilcar.lucas@iav.de>
7+
8+
SPDX-License-Identifier: GPL-3.0-or-later
9+
"""
10+
11+
import json
12+
import logging
13+
import os
14+
from pathlib import Path
15+
from typing import Any, Optional
16+
17+
from platformdirs import user_data_dir
18+
19+
from ardupilot_methodic_configurator.data_model_signing_config import (
20+
CONFIG_VERSION,
21+
VehicleSigningConfig,
22+
)
23+
24+
25+
class SigningConfigManager:
26+
"""
27+
Manager for loading and saving signing configurations.
28+
29+
This class handles persistence of signing configurations to JSON files
30+
with version control and concurrent access protection.
31+
32+
Config file format:
33+
{
34+
"version": 1,
35+
"configs": {
36+
"vehicle_id": {...}
37+
}
38+
}
39+
"""
40+
41+
def __init__(self, config_dir: Optional[Path] = None) -> None:
42+
"""
43+
Initialize the configuration manager.
44+
45+
Args:
46+
config_dir: Directory for storing configuration files.
47+
If None, uses platform-appropriate data directory.
48+
49+
"""
50+
self._logger = logging.getLogger(__name__)
51+
52+
if config_dir is None:
53+
config_dir = Path(user_data_dir("ArduPilot Methodic Configurator"))
54+
55+
self._config_dir = config_dir
56+
self._config_file = config_dir / "signing_configs.json"
57+
58+
def load_vehicle_config(self, vehicle_id: str) -> Optional[VehicleSigningConfig]:
59+
"""
60+
Load signing configuration for a specific vehicle.
61+
62+
Args:
63+
vehicle_id: Vehicle identifier
64+
65+
Returns:
66+
VehicleSigningConfig if found, None otherwise
67+
68+
"""
69+
configs = self._load_all_configs()
70+
if vehicle_id in configs:
71+
try:
72+
return VehicleSigningConfig.from_dict(configs[vehicle_id])
73+
except (KeyError, ValueError) as exc:
74+
self._logger.warning("Failed to load config for %s: %s", vehicle_id, exc)
75+
return None
76+
77+
def save_vehicle_config(self, config: VehicleSigningConfig) -> bool:
78+
"""
79+
Save signing configuration for a vehicle.
80+
81+
Args:
82+
config: Vehicle signing configuration to save
83+
84+
Returns:
85+
bool: True if saved successfully
86+
87+
"""
88+
try:
89+
configs = self._load_all_configs()
90+
configs[config.vehicle_id] = config.to_dict()
91+
self._save_all_configs(configs)
92+
return True
93+
except Exception as exc: # pylint: disable=broad-except
94+
self._logger.exception("Failed to save config: %s", exc)
95+
return False
96+
97+
def delete_vehicle_config(self, vehicle_id: str) -> bool:
98+
"""
99+
Delete signing configuration for a vehicle.
100+
101+
Args:
102+
vehicle_id: Vehicle identifier
103+
104+
Returns:
105+
bool: True if deleted, False if not found
106+
107+
"""
108+
try:
109+
configs = self._load_all_configs()
110+
if vehicle_id in configs:
111+
del configs[vehicle_id]
112+
self._save_all_configs(configs)
113+
return True
114+
return False
115+
except Exception as exc: # pylint: disable=broad-except
116+
self._logger.exception("Failed to delete config: %s", exc)
117+
return False
118+
119+
def list_configured_vehicles(self) -> list[str]:
120+
"""
121+
List all vehicles with saved configurations.
122+
123+
Returns:
124+
list[str]: List of vehicle IDs
125+
126+
"""
127+
configs = self._load_all_configs()
128+
return sorted(configs.keys())
129+
130+
def _load_all_configs(self) -> dict[str, Any]:
131+
"""Load all configurations from file with version handling."""
132+
if not self._config_file.exists():
133+
return {}
134+
135+
try:
136+
with open(self._config_file, encoding="utf-8") as f:
137+
data: dict[str, Any] = json.load(f)
138+
139+
if not isinstance(data, dict):
140+
self._logger.warning("Config file contains invalid data structure (not a dictionary)")
141+
return {}
142+
143+
version = data.get("version", 1)
144+
if version > CONFIG_VERSION:
145+
self._logger.warning(
146+
"Config file version %d is newer than supported version %d. Some features may not work correctly.",
147+
version,
148+
CONFIG_VERSION,
149+
)
150+
151+
configs: dict[str, Any] = data.get("configs", data)
152+
configs.pop("version", None)
153+
return configs
154+
155+
except (json.JSONDecodeError, OSError) as exc:
156+
self._logger.warning("Failed to load configs: %s", exc)
157+
return {}
158+
159+
def _save_all_configs(self, configs: dict[str, Any]) -> None:
160+
"""Save all configurations to file with version and file locking."""
161+
self._config_dir.mkdir(parents=True, exist_ok=True)
162+
163+
versioned_data = {
164+
"version": CONFIG_VERSION,
165+
"configs": configs,
166+
}
167+
168+
temp_file = self._config_file.with_suffix(".tmp")
169+
try:
170+
with open(temp_file, "w", encoding="utf-8") as f:
171+
if os.name != "nt":
172+
import fcntl # noqa: PLC0415 # pylint: disable=import-outside-toplevel
173+
174+
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
175+
json.dump(versioned_data, f, indent=2)
176+
177+
os.replace(temp_file, self._config_file)
178+
179+
if os.name != "nt":
180+
os.chmod(self._config_file, 0o600)
181+
182+
except Exception:
183+
if temp_file.exists():
184+
temp_file.unlink()
185+
raise

0 commit comments

Comments
 (0)