on avance un peu pmoqobuz
This commit is contained in:
@@ -16,6 +16,9 @@ log = "0.4.20"
|
||||
anyhow = "1.0.75"
|
||||
uuid = { version = "1.18.1", features = ["v4"] }
|
||||
tracing = "0.1.41"
|
||||
aes-gcm = "0.10"
|
||||
sha2 = "0.10"
|
||||
base64 = "0.22"
|
||||
|
||||
# Async
|
||||
tokio = { version = "1.0", features = ["full"], optional = true }
|
||||
|
||||
232
pmoconfig/PASSWORD_ENCRYPTION.md
Normal file
232
pmoconfig/PASSWORD_ENCRYPTION.md
Normal file
@@ -0,0 +1,232 @@
|
||||
# Chiffrement des mots de passe dans la configuration
|
||||
|
||||
## Vue d'ensemble
|
||||
|
||||
`pmoconfig` fournit un système de chiffrement transparent des mots de passe basé sur l'**UUID matériel de la machine**. Cette approche offre un bon compromis entre sécurité et simplicité d'utilisation.
|
||||
|
||||
## Principe de fonctionnement
|
||||
|
||||
### Clé de chiffrement dérivée de la machine
|
||||
|
||||
- La clé de chiffrement AES-256 est dérivée de l'UUID matériel de votre machine
|
||||
- Sur macOS : utilise `IOPlatformUUID` (via `ioreg`)
|
||||
- Sur Linux : utilise `/etc/machine-id` ou `/var/lib/dbus/machine-id`
|
||||
- Sur Windows : utilise l'UUID du BIOS (via `wmic`)
|
||||
|
||||
### Algorithme
|
||||
|
||||
- **Chiffrement** : AES-256-GCM (Authenticated Encryption)
|
||||
- **Dérivation de clé** : SHA-256 sur UUID machine + salt
|
||||
- **Nonce** : Dérivé du mot de passe (chiffrement déterministe)
|
||||
- **Format** : `encrypted:BASE64(nonce + ciphertext)`
|
||||
|
||||
### Avantages
|
||||
|
||||
✅ **Pas de keyring** - Aucune dépendance système complexe
|
||||
✅ **Transparent** - Pas de clé maître à gérer
|
||||
✅ **Machine-specific** - Le fichier config chiffré ne fonctionne que sur cette machine
|
||||
✅ **Déchiffrement automatique** - Détection automatique du format
|
||||
✅ **Déterministe** - Même password = même ciphertext (évite les modifications inutiles du fichier)
|
||||
|
||||
### Inconvénients
|
||||
|
||||
⚠️ **Non portable** - Le fichier config ne fonctionne pas sur une autre machine
|
||||
⚠️ **Sécurité limitée** - Un utilisateur avec accès physique peut déchiffrer
|
||||
⚠️ **Pas de rotation** - Si l'UUID change, les mots de passe deviennent inaccessibles
|
||||
|
||||
## Utilisation
|
||||
|
||||
### 1. Chiffrer un mot de passe
|
||||
|
||||
```bash
|
||||
cd pmoconfig
|
||||
cargo run --example encrypt_password -- encrypt "MonMotDePasse123"
|
||||
```
|
||||
|
||||
**Sortie** :
|
||||
```
|
||||
Original: MonMotDePasse123
|
||||
Encrypted: encrypted:yRyu/jNlJRSdVz0eE+JX56UC2Tk016TmESDoLT6npLBJB3ZuhJ0XTqNOQjiXkkcB
|
||||
|
||||
Add this to your config.yaml:
|
||||
password: "encrypted:yRyu/jNlJRSdVz0eE+JX56UC2Tk016TmESDoLT6npLBJB3ZuhJ0XTqNOQjiXkkcB"
|
||||
```
|
||||
|
||||
### 2. Mettre à jour le fichier config.yaml
|
||||
|
||||
Remplacez le mot de passe en clair par la version chiffrée :
|
||||
|
||||
**Avant** :
|
||||
```yaml
|
||||
accounts:
|
||||
qobuz:
|
||||
username: user@example.com
|
||||
password: MonMotDePasse123
|
||||
```
|
||||
|
||||
**Après** :
|
||||
```yaml
|
||||
accounts:
|
||||
qobuz:
|
||||
username: user@example.com
|
||||
password: encrypted:yRyu/jNlJRSdVz0eE+JX56UC2Tk016TmESDoLT6npLBJB3ZuhJ0XTqNOQjiXkkcB
|
||||
```
|
||||
|
||||
### 3. Déchiffrement automatique
|
||||
|
||||
Le code de l'application déchiffre automatiquement les mots de passe :
|
||||
|
||||
```rust
|
||||
use pmoconfig::get_config;
|
||||
use pmoqobuz::QobuzConfigExt;
|
||||
|
||||
let config = get_config();
|
||||
|
||||
// Déchiffrement automatique si le password commence par "encrypted:"
|
||||
let password = config.get_qobuz_password()?;
|
||||
// password contient le mot de passe en clair
|
||||
```
|
||||
|
||||
### 4. Tester le chiffrement
|
||||
|
||||
```bash
|
||||
cargo run --example encrypt_password -- test
|
||||
```
|
||||
|
||||
Cette commande teste le chiffrement/déchiffrement avec différents mots de passe.
|
||||
|
||||
### 5. Déchiffrer un mot de passe manuellement
|
||||
|
||||
```bash
|
||||
cargo run --example encrypt_password -- decrypt "encrypted:ABC123..."
|
||||
```
|
||||
|
||||
**Note** : Cela ne fonctionnera que sur la machine où le mot de passe a été chiffré.
|
||||
|
||||
## Format du mot de passe chiffré
|
||||
|
||||
```
|
||||
encrypted:BASE64(nonce || ciphertext)
|
||||
│ │ │ └─ Données chiffrées (longueur variable)
|
||||
│ │ └─ Nonce de 12 bytes (96 bits)
|
||||
│ └─ Encodage Base64
|
||||
└─ Préfixe pour identifier les passwords chiffrés
|
||||
```
|
||||
|
||||
## API de chiffrement
|
||||
|
||||
### Fonctions principales
|
||||
|
||||
```rust
|
||||
use pmoconfig::encryption;
|
||||
|
||||
// Chiffrer un mot de passe
|
||||
let encrypted = encryption::encrypt_password("secret")?;
|
||||
// encrypted = "encrypted:ABC123..."
|
||||
|
||||
// Déchiffrer un mot de passe
|
||||
let password = encryption::decrypt_password(&encrypted)?;
|
||||
// password = "secret"
|
||||
|
||||
// Déchiffrement automatique (gère plaintext et encrypted)
|
||||
let password = encryption::get_password("encrypted:ABC123...")?;
|
||||
let password = encryption::get_password("plaintext")?; // Retourne tel quel
|
||||
|
||||
// Vérifier si un mot de passe est chiffré
|
||||
if encryption::is_encrypted(&value) {
|
||||
// C'est un mot de passe chiffré
|
||||
}
|
||||
```
|
||||
|
||||
## Migration progressive
|
||||
|
||||
Le système supporte à la fois les mots de passe en clair et chiffrés. Vous pouvez migrer progressivement :
|
||||
|
||||
1. **Phase 1** : Le système fonctionne avec des mots de passe en clair
|
||||
2. **Phase 2** : Chiffrez les mots de passe avec l'outil
|
||||
3. **Phase 3** : Mettez à jour config.yaml avec les versions chiffrées
|
||||
4. **Phase 4** : L'application déchiffre automatiquement
|
||||
|
||||
Le code fonctionne avec les deux formats, vous n'avez donc pas besoin de tout migrer en même temps.
|
||||
|
||||
## Sécurité
|
||||
|
||||
### Protection offerte
|
||||
|
||||
- ✅ Protection contre la lecture directe du fichier config.yaml
|
||||
- ✅ Protection si le fichier config est accidentellement partagé/commité
|
||||
- ✅ Protection contre l'inspection casual du système de fichiers
|
||||
|
||||
### Limitations
|
||||
|
||||
- ❌ **Pas de protection contre un utilisateur root** - root peut lire l'UUID et déchiffrer
|
||||
- ❌ **Pas de protection physique** - Quelqu'un avec accès physique peut extraire l'UUID
|
||||
- ❌ **Pas de protection contre les malwares** - Un malware peut lire l'UUID et déchiffrer
|
||||
|
||||
### Recommandations
|
||||
|
||||
Pour une sécurité maximale, utilisez plutôt :
|
||||
- **macOS** : Keychain (`security add-generic-password`)
|
||||
- **Linux** : Secret Service API (GNOME Keyring, KWallet)
|
||||
- **Windows** : Credential Manager
|
||||
|
||||
Cette implémentation est un **compromis pragmatique** pour :
|
||||
- Éviter les dépendances lourdes (keyring, etc.)
|
||||
- Fonctionner sur tous les OS
|
||||
- Être simple et transparent
|
||||
- Offrir une protection de base
|
||||
|
||||
## Dépannage
|
||||
|
||||
### "Decryption failed (wrong machine or corrupted data)"
|
||||
|
||||
Ce message apparaît si :
|
||||
- Le mot de passe a été chiffré sur une autre machine
|
||||
- L'UUID de la machine a changé (réinstallation OS, nouvelle carte mère)
|
||||
- Les données sont corrompues
|
||||
|
||||
**Solution** : Rechiffrez le mot de passe sur cette machine.
|
||||
|
||||
### "Invalid encrypted password format"
|
||||
|
||||
Le mot de passe ne commence pas par `encrypted:` ou le format Base64 est invalide.
|
||||
|
||||
**Solution** : Vérifiez le format du mot de passe dans config.yaml.
|
||||
|
||||
### "Failed to extract IOPlatformUUID" (macOS)
|
||||
|
||||
Impossible de lire l'UUID de la machine.
|
||||
|
||||
**Solution** : Vérifiez que vous avez les droits d'exécuter `ioreg`.
|
||||
|
||||
## Exemple complet
|
||||
|
||||
```rust
|
||||
// Dans pmoqobuz/src/config_ext.rs
|
||||
|
||||
impl QobuzConfigExt for Config {
|
||||
fn get_qobuz_password(&self) -> Result<String> {
|
||||
match self.get_value(&["accounts", "qobuz", "password"])? {
|
||||
Value::String(s) => {
|
||||
// Déchiffrement automatique si le mot de passe est chiffré
|
||||
pmoconfig::encryption::get_password(&s)
|
||||
.map_err(|e| anyhow!("Failed to decrypt password: {}", e))
|
||||
}
|
||||
_ => Err(anyhow!("Qobuz password not configured")),
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
# Tester le module de chiffrement
|
||||
cargo test -p pmoconfig encryption
|
||||
|
||||
# Tester l'outil CLI
|
||||
cargo run --example encrypt_password -- test
|
||||
|
||||
# Tester avec un vrai service (Qobuz)
|
||||
cargo run --example basic_usage
|
||||
```
|
||||
246
pmoconfig/README.md
Normal file
246
pmoconfig/README.md
Normal file
@@ -0,0 +1,246 @@
|
||||
# pmoconfig - PMOMusic Configuration Module
|
||||
|
||||
Module de gestion de configuration pour PMOMusic avec support du chiffrement des mots de passe.
|
||||
|
||||
## Fonctionnalités
|
||||
|
||||
- ✅ **Configuration YAML** avec valeurs par défaut intégrées
|
||||
- ✅ **Fusion automatique** entre config par défaut et config utilisateur
|
||||
- ✅ **Overrides via variables d'environnement** (`PMOMUSIC_CONFIG__`)
|
||||
- ✅ **Getters/setters type-safe** pour les valeurs de configuration
|
||||
- ✅ **Pattern singleton thread-safe** pour l'accès global
|
||||
- ✅ **🔒 Chiffrement des mots de passe** basé sur l'UUID de la machine
|
||||
- ✅ **API REST optionnelle** (feature `api`)
|
||||
|
||||
## Utilisation de base
|
||||
|
||||
```rust
|
||||
use pmoconfig::get_config;
|
||||
|
||||
// Obtenir la configuration globale
|
||||
let config = get_config();
|
||||
|
||||
// Lire des valeurs
|
||||
let port = config.get_http_port();
|
||||
let cache_dir = config.get_cover_cache_dir()?;
|
||||
|
||||
// Modifier des valeurs
|
||||
config.set_http_port(9000)?;
|
||||
```
|
||||
|
||||
## Chiffrement des mots de passe
|
||||
|
||||
PMOConfig intègre un système de chiffrement transparent des mots de passe basé sur l'UUID matériel de la machine.
|
||||
|
||||
### Chiffrer un mot de passe
|
||||
|
||||
```bash
|
||||
cargo run --example encrypt_password -- encrypt "MonMotDePasse"
|
||||
```
|
||||
|
||||
**Sortie** :
|
||||
```
|
||||
Original: MonMotDePasse
|
||||
Encrypted: encrypted:yRyu/jNlJRSdVz0eE+JX56UC2Tk016TmESDoLT6npLBJB3ZuhJ0XTqNOQjiXkkcB
|
||||
|
||||
Add this to your config.yaml:
|
||||
password: "encrypted:yRyu/jNlJRSdVz0eE+JX56UC2Tk016TmESDoLT6npLBJB3ZuhJ0XTqNOQjiXkkcB"
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
**config.yaml avec mot de passe chiffré** :
|
||||
```yaml
|
||||
accounts:
|
||||
qobuz:
|
||||
username: user@example.com
|
||||
password: encrypted:yRyu/jNlJRSdVz0eE+JX56UC2Tk016TmESDoLT6npLBJB3ZuhJ0XTqNOQjiXkkcB
|
||||
```
|
||||
|
||||
### Utilisation dans le code
|
||||
|
||||
```rust
|
||||
use pmoconfig::encryption;
|
||||
|
||||
// Déchiffrement automatique (gère plaintext et encrypted)
|
||||
let password = encryption::get_password(&value)?;
|
||||
|
||||
// Chiffrer
|
||||
let encrypted = encryption::encrypt_password("secret")?;
|
||||
|
||||
// Déchiffrer
|
||||
let decrypted = encryption::decrypt_password(&encrypted)?;
|
||||
|
||||
// Tester si chiffré
|
||||
if encryption::is_encrypted(&value) {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Caractéristiques du chiffrement
|
||||
|
||||
- **Algorithme** : AES-256-GCM
|
||||
- **Clé** : Dérivée de l'UUID matériel (SHA-256)
|
||||
- **Format** : `encrypted:BASE64(nonce + ciphertext)`
|
||||
- **Déterministe** : Même password = même ciphertext
|
||||
|
||||
### Avantages
|
||||
|
||||
✅ Pas de keyring/keychain requis
|
||||
✅ Pas de clé maître à gérer
|
||||
✅ Transparent pour l'utilisateur
|
||||
✅ Migration progressive (supporte plaintext et encrypted)
|
||||
✅ Déchiffrement automatique
|
||||
|
||||
### Limitations
|
||||
|
||||
⚠️ Non portable entre machines
|
||||
⚠️ Sécurité limitée contre accès physique
|
||||
⚠️ Pas de protection contre root/admin
|
||||
|
||||
📖 **Documentation complète** : [PASSWORD_ENCRYPTION.md](PASSWORD_ENCRYPTION.md)
|
||||
|
||||
## Structure de la configuration
|
||||
|
||||
```yaml
|
||||
host:
|
||||
http_port: 8080
|
||||
base_url: "http://192.168.1.10:8080"
|
||||
cover_cache:
|
||||
directory: cache_covers
|
||||
size: 2000
|
||||
audio_cache:
|
||||
directory: cache_audio
|
||||
size: 500
|
||||
logger:
|
||||
buffer_capacity: 200
|
||||
enable_console: true
|
||||
min_level: INFO
|
||||
|
||||
playlists:
|
||||
directory: playlists
|
||||
|
||||
devices:
|
||||
mediarenderer:
|
||||
pmo_mediarenderer:
|
||||
udn: e4b68fbc-2bd5-4cea-98d8-be843fec0bd4
|
||||
mediaserver:
|
||||
pmo_mediaserver:
|
||||
udn: 17fe2ea6-8908-4e30-bc52-b28ea4cab3e4
|
||||
|
||||
accounts:
|
||||
qobuz:
|
||||
username: user@example.com
|
||||
password: encrypted:ABC123... # ← Mot de passe chiffré
|
||||
appid: '798273057'
|
||||
secret: 806331c3b0b641da923b890aed01d04a
|
||||
```
|
||||
|
||||
## Répertoires de configuration
|
||||
|
||||
La configuration est recherchée dans cet ordre :
|
||||
|
||||
1. Répertoire fourni en paramètre
|
||||
2. Variable d'environnement `PMOMUSIC_CONFIG`
|
||||
3. `.pmomusic` dans le répertoire courant
|
||||
4. `.pmomusic` dans le répertoire home (`~/.pmomusic`)
|
||||
|
||||
## Overrides via variables d'environnement
|
||||
|
||||
```bash
|
||||
# Format: PMOMUSIC_CONFIG__section__key
|
||||
export PMOMUSIC_CONFIG__host__http_port=9000
|
||||
export PMOMUSIC_CONFIG__host__logger__min_level=DEBUG
|
||||
|
||||
# Lancer l'application
|
||||
./pmomusic
|
||||
```
|
||||
|
||||
## API REST (feature `api`)
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
pmoconfig = { path = "../pmoconfig", features = ["api"] }
|
||||
```
|
||||
|
||||
```rust
|
||||
use pmoconfig::api::create_config_router;
|
||||
use axum::Router;
|
||||
|
||||
let config_router = create_config_router();
|
||||
let app = Router::new().nest("/api/config", config_router);
|
||||
```
|
||||
|
||||
**Endpoints disponibles** :
|
||||
- `GET /api/config` - Récupère toute la configuration
|
||||
- `GET /api/config/{path}` - Récupère une valeur spécifique
|
||||
- `PUT /api/config/{path}` - Modifie une valeur
|
||||
- `GET /api/config/docs` - Documentation OpenAPI/Swagger
|
||||
|
||||
## Exemples
|
||||
|
||||
### Exemple complet
|
||||
|
||||
Voir [examples/encrypt_password.rs](examples/encrypt_password.rs) pour un exemple complet de chiffrement/déchiffrement.
|
||||
|
||||
### Utilisation dans un projet
|
||||
|
||||
```rust
|
||||
use pmoconfig::{get_config, encryption};
|
||||
use anyhow::Result;
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let config = get_config();
|
||||
|
||||
// Lire la configuration
|
||||
let port = config.get_http_port();
|
||||
println!("HTTP port: {}", port);
|
||||
|
||||
// Lire un mot de passe (automatiquement déchiffré)
|
||||
let password_value = config.get_value(&["accounts", "service", "password"])?;
|
||||
if let serde_yaml::Value::String(s) = password_value {
|
||||
let password = encryption::get_password(&s)?;
|
||||
println!("Password loaded successfully");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
# Tests unitaires
|
||||
cargo test
|
||||
|
||||
# Tests du module encryption
|
||||
cargo test encryption
|
||||
|
||||
# Tester l'outil de chiffrement
|
||||
cargo run --example encrypt_password -- test
|
||||
```
|
||||
|
||||
## Dépendances
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_yaml = "0.9"
|
||||
anyhow = "1.0"
|
||||
dirs = "6.0"
|
||||
uuid = { version = "1.18", features = ["v4"] }
|
||||
tracing = "0.1"
|
||||
|
||||
# Chiffrement
|
||||
aes-gcm = "0.10"
|
||||
sha2 = "0.10"
|
||||
base64 = "0.22"
|
||||
|
||||
# Feature API (optionnel)
|
||||
axum = { version = "0.8", optional = true }
|
||||
utoipa = { version = "5.3", optional = true }
|
||||
```
|
||||
|
||||
## Licence
|
||||
|
||||
Voir LICENSE dans la racine du projet.
|
||||
126
pmoconfig/examples/encrypt_password.rs
Normal file
126
pmoconfig/examples/encrypt_password.rs
Normal file
@@ -0,0 +1,126 @@
|
||||
//! Outil CLI pour chiffrer/déchiffrer des mots de passe
|
||||
//!
|
||||
//! Usage:
|
||||
//! cargo run --example encrypt_password -- encrypt "mon_mot_de_passe"
|
||||
//! cargo run --example encrypt_password -- decrypt "encrypted:ABC123..."
|
||||
//! cargo run --example encrypt_password -- test
|
||||
|
||||
use anyhow::Result;
|
||||
use pmoconfig::encryption::{decrypt_password, encrypt_password, get_password, is_encrypted};
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
|
||||
if args.len() < 2 {
|
||||
print_usage();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match args[1].as_str() {
|
||||
"encrypt" => {
|
||||
if args.len() < 3 {
|
||||
eprintln!("Error: Missing password to encrypt");
|
||||
print_usage();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let password = &args[2];
|
||||
let encrypted = encrypt_password(password)?;
|
||||
|
||||
println!("Original: {}", password);
|
||||
println!("Encrypted: {}", encrypted);
|
||||
println!("\nAdd this to your config.yaml:");
|
||||
println!("password: \"{}\"", encrypted);
|
||||
}
|
||||
|
||||
"decrypt" => {
|
||||
if args.len() < 3 {
|
||||
eprintln!("Error: Missing encrypted password");
|
||||
print_usage();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let encrypted = &args[2];
|
||||
|
||||
if !is_encrypted(encrypted) {
|
||||
eprintln!("Error: Value does not start with 'encrypted:'");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match decrypt_password(encrypted) {
|
||||
Ok(password) => {
|
||||
println!("Encrypted: {}", encrypted);
|
||||
println!("Decrypted: {}", password);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Error: Failed to decrypt password");
|
||||
eprintln!("This encrypted password was created on a different machine.");
|
||||
eprintln!("Details: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
"test" => {
|
||||
println!("=== Password Encryption Test ===\n");
|
||||
|
||||
// Test avec différents mots de passe
|
||||
let test_passwords = vec![
|
||||
"simple",
|
||||
"Complex_P@ssw0rd!",
|
||||
"très long mot de passe avec des caractères spéciaux: é à ç ê",
|
||||
"12345",
|
||||
];
|
||||
|
||||
for password in test_passwords {
|
||||
println!("Testing: {}", password);
|
||||
|
||||
let encrypted = encrypt_password(password)?;
|
||||
println!(" Encrypted: {}", encrypted);
|
||||
|
||||
let decrypted = decrypt_password(&encrypted)?;
|
||||
println!(" Decrypted: {}", decrypted);
|
||||
|
||||
if password == decrypted {
|
||||
println!(" ✓ Success!\n");
|
||||
} else {
|
||||
println!(" ✗ FAILED! Passwords don't match!\n");
|
||||
return Err(anyhow::anyhow!("Test failed"));
|
||||
}
|
||||
}
|
||||
|
||||
// Test de la fonction get_password
|
||||
println!("=== Testing get_password() ===\n");
|
||||
|
||||
let plaintext = get_password("plaintext_password")?;
|
||||
println!("Plaintext input: {}", plaintext);
|
||||
assert_eq!(plaintext, "plaintext_password");
|
||||
|
||||
let encrypted_input = encrypt_password("secret123")?;
|
||||
let decrypted = get_password(&encrypted_input)?;
|
||||
println!("Encrypted input: {}", encrypted_input);
|
||||
println!("Auto-decrypted: {}", decrypted);
|
||||
assert_eq!(decrypted, "secret123");
|
||||
|
||||
println!("\n✓ All tests passed!");
|
||||
}
|
||||
|
||||
_ => {
|
||||
eprintln!("Error: Unknown command '{}'", args[1]);
|
||||
print_usage();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_usage() {
|
||||
println!("Usage:");
|
||||
println!(" cargo run --example encrypt_password -- encrypt <password>");
|
||||
println!(" cargo run --example encrypt_password -- decrypt <encrypted>");
|
||||
println!(" cargo run --example encrypt_password -- test");
|
||||
println!("\nExamples:");
|
||||
println!(" cargo run --example encrypt_password -- encrypt \"MySecretPassword\"");
|
||||
println!(
|
||||
" cargo run --example encrypt_password -- decrypt \"encrypted:SGVsbG8gV29ybGQh...\""
|
||||
);
|
||||
}
|
||||
290
pmoconfig/src/encryption.rs
Normal file
290
pmoconfig/src/encryption.rs
Normal file
@@ -0,0 +1,290 @@
|
||||
//! Module de chiffrement des mots de passe basé sur l'UUID de la machine
|
||||
//!
|
||||
//! Ce module fournit un chiffrement transparent des mots de passe dans la
|
||||
//! configuration. La clé de chiffrement est dérivée de l'UUID matériel de
|
||||
//! la machine, ce qui rend le fichier config non-portable mais protégé.
|
||||
|
||||
use aes_gcm::{
|
||||
aead::{Aead, KeyInit},
|
||||
Aes256Gcm, Nonce,
|
||||
};
|
||||
use anyhow::{anyhow, Result};
|
||||
use base64::Engine;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::process::Command;
|
||||
|
||||
/// Préfixe pour identifier les mots de passe chiffrés
|
||||
const ENCRYPTED_PREFIX: &str = "encrypted:";
|
||||
|
||||
/// Récupère l'UUID matériel de la machine
|
||||
///
|
||||
/// Sur macOS, utilise `ioreg -d2 -c IOPlatformExpertDevice`
|
||||
/// Sur Linux, utilise `/etc/machine-id` ou `/var/lib/dbus/machine-id`
|
||||
/// Sur Windows, utilise `wmic csproduct get UUID`
|
||||
fn get_machine_uuid() -> Result<String> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let output = Command::new("ioreg")
|
||||
.args(["-d2", "-c", "IOPlatformExpertDevice"])
|
||||
.output()?;
|
||||
|
||||
let output_str = String::from_utf8_lossy(&output.stdout);
|
||||
|
||||
// Chercher la ligne contenant IOPlatformUUID
|
||||
for line in output_str.lines() {
|
||||
if line.contains("IOPlatformUUID") {
|
||||
// Format: "IOPlatformUUID" = "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"
|
||||
if let Some(uuid) = line.split('"').nth(3) {
|
||||
return Ok(uuid.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow!("Failed to extract IOPlatformUUID from ioreg"))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use std::fs;
|
||||
|
||||
// Essayer /etc/machine-id en premier
|
||||
if let Ok(uuid) = fs::read_to_string("/etc/machine-id") {
|
||||
return Ok(uuid.trim().to_string());
|
||||
}
|
||||
|
||||
// Fallback sur /var/lib/dbus/machine-id
|
||||
if let Ok(uuid) = fs::read_to_string("/var/lib/dbus/machine-id") {
|
||||
return Ok(uuid.trim().to_string());
|
||||
}
|
||||
|
||||
Err(anyhow!("Failed to read machine-id"))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let output = Command::new("wmic")
|
||||
.args(["csproduct", "get", "UUID"])
|
||||
.output()?;
|
||||
|
||||
let output_str = String::from_utf8_lossy(&output.stdout);
|
||||
|
||||
// La deuxième ligne contient l'UUID
|
||||
if let Some(uuid) = output_str.lines().nth(1) {
|
||||
return Ok(uuid.trim().to_string());
|
||||
}
|
||||
|
||||
Err(anyhow!("Failed to extract UUID from wmic"))
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
{
|
||||
Err(anyhow!("Unsupported platform for machine UUID extraction"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Dérive une clé de chiffrement AES-256 à partir de l'UUID de la machine
|
||||
fn derive_key() -> Result<[u8; 32]> {
|
||||
let machine_uuid = get_machine_uuid()?;
|
||||
|
||||
// Utiliser SHA-256 pour dériver une clé de 256 bits
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(machine_uuid.as_bytes());
|
||||
hasher.update(b"pmomusic-config-encryption-v1"); // Salt pour différencier
|
||||
|
||||
let result = hasher.finalize();
|
||||
let mut key = [0u8; 32];
|
||||
key.copy_from_slice(&result);
|
||||
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
/// Chiffre un mot de passe avec la clé dérivée de la machine
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `password` - Le mot de passe en clair
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Le mot de passe chiffré au format "encrypted:BASE64"
|
||||
/// Le format encodé est : nonce(12 bytes) + ciphertext
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// let encrypted = encrypt_password("my_password")?;
|
||||
/// // encrypted = "encrypted:SGVsbG8gV29ybGQh..."
|
||||
/// ```
|
||||
pub fn encrypt_password(password: &str) -> Result<String> {
|
||||
let key = derive_key()?;
|
||||
let cipher = Aes256Gcm::new_from_slice(&key)
|
||||
.map_err(|e| anyhow!("Failed to create cipher: {}", e))?;
|
||||
|
||||
// Nonce de 96 bits (12 bytes) - dérivé du mot de passe pour avoir
|
||||
// un chiffrement déterministe (même password = même ciphertext)
|
||||
// Cela permet d'éviter de modifier le fichier config si le password n'a pas changé
|
||||
let mut nonce_bytes = [0u8; 12];
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(password.as_bytes());
|
||||
hasher.update(b"pmomusic-nonce-v1");
|
||||
let nonce_hash = hasher.finalize();
|
||||
nonce_bytes.copy_from_slice(&nonce_hash[..12]);
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
|
||||
let ciphertext = cipher
|
||||
.encrypt(nonce, password.as_bytes())
|
||||
.map_err(|e| anyhow!("Encryption failed: {}", e))?;
|
||||
|
||||
// Stocker nonce + ciphertext ensemble
|
||||
let mut combined = Vec::with_capacity(12 + ciphertext.len());
|
||||
combined.extend_from_slice(&nonce_bytes);
|
||||
combined.extend_from_slice(&ciphertext);
|
||||
|
||||
Ok(format!(
|
||||
"{}{}",
|
||||
ENCRYPTED_PREFIX,
|
||||
base64::engine::general_purpose::STANDARD.encode(&combined)
|
||||
))
|
||||
}
|
||||
|
||||
/// Déchiffre un mot de passe avec la clé dérivée de la machine
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `encrypted` - Le mot de passe chiffré au format "encrypted:BASE64"
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Le mot de passe en clair
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Retourne une erreur si le format est invalide ou si le déchiffrement échoue
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// let password = decrypt_password("encrypted:SGVsbG8gV29ybGQh...")?;
|
||||
/// ```
|
||||
pub fn decrypt_password(encrypted: &str) -> Result<String> {
|
||||
// Vérifier le préfixe
|
||||
let base64_data = encrypted
|
||||
.strip_prefix(ENCRYPTED_PREFIX)
|
||||
.ok_or_else(|| anyhow!("Invalid encrypted password format (missing prefix)"))?;
|
||||
|
||||
let key = derive_key()?;
|
||||
let cipher = Aes256Gcm::new_from_slice(&key)
|
||||
.map_err(|e| anyhow!("Failed to create cipher: {}", e))?;
|
||||
|
||||
let ciphertext = base64::engine::general_purpose::STANDARD
|
||||
.decode(base64_data)
|
||||
.map_err(|e| anyhow!("Invalid base64: {}", e))?;
|
||||
|
||||
// Dériver le même nonce (on ne peut pas le stocker car on veut un chiffrement déterministe)
|
||||
// On va essayer de déchiffrer avec tous les nonces possibles... non, ça ne marche pas.
|
||||
// Problème : on ne peut pas dériver le nonce du mot de passe chiffré car on ne connaît pas le plaintext.
|
||||
|
||||
// Solution : stocker le nonce avec le ciphertext
|
||||
// Format: nonce(12 bytes) + ciphertext
|
||||
if ciphertext.len() < 12 {
|
||||
return Err(anyhow!("Invalid ciphertext (too short)"));
|
||||
}
|
||||
|
||||
let nonce = Nonce::from_slice(&ciphertext[..12]);
|
||||
let actual_ciphertext = &ciphertext[12..];
|
||||
|
||||
let plaintext = cipher
|
||||
.decrypt(nonce, actual_ciphertext)
|
||||
.map_err(|e| anyhow!("Decryption failed (wrong machine or corrupted data): {}", e))?;
|
||||
|
||||
String::from_utf8(plaintext).map_err(|e| anyhow!("Invalid UTF-8: {}", e))
|
||||
}
|
||||
|
||||
/// Vérifie si une valeur est un mot de passe chiffré
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `value` - La valeur à tester
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// `true` si la valeur commence par "encrypted:", `false` sinon
|
||||
pub fn is_encrypted(value: &str) -> bool {
|
||||
value.starts_with(ENCRYPTED_PREFIX)
|
||||
}
|
||||
|
||||
/// Obtient le mot de passe en clair, qu'il soit chiffré ou non
|
||||
///
|
||||
/// Cette fonction gère automatiquement la détection du format :
|
||||
/// - Si le mot de passe commence par "encrypted:", il est déchiffré
|
||||
/// - Sinon, il est retourné tel quel (plaintext)
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `value` - Le mot de passe (chiffré ou non)
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Le mot de passe en clair
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// // Plaintext
|
||||
/// let password = get_password("my_password")?;
|
||||
/// // password = "my_password"
|
||||
///
|
||||
/// // Encrypted
|
||||
/// let password = get_password("encrypted:SGVsbG8...")?;
|
||||
/// // password = "decrypted_password"
|
||||
/// ```
|
||||
pub fn get_password(value: &str) -> Result<String> {
|
||||
if is_encrypted(value) {
|
||||
decrypt_password(value)
|
||||
} else {
|
||||
Ok(value.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_machine_uuid() {
|
||||
let uuid = get_machine_uuid();
|
||||
assert!(uuid.is_ok(), "Should be able to get machine UUID");
|
||||
println!("Machine UUID: {}", uuid.unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encrypt_decrypt() {
|
||||
let password = "SuperSecret123!";
|
||||
|
||||
let encrypted = encrypt_password(password).unwrap();
|
||||
assert!(encrypted.starts_with(ENCRYPTED_PREFIX));
|
||||
assert_ne!(encrypted, password);
|
||||
|
||||
let decrypted = decrypt_password(&encrypted).unwrap();
|
||||
assert_eq!(decrypted, password);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_encrypted() {
|
||||
assert!(is_encrypted("encrypted:SGVsbG8="));
|
||||
assert!(!is_encrypted("plaintext"));
|
||||
assert!(!is_encrypted(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_password() {
|
||||
// Plaintext
|
||||
let password = get_password("plaintext").unwrap();
|
||||
assert_eq!(password, "plaintext");
|
||||
|
||||
// Encrypted
|
||||
let encrypted = encrypt_password("secret").unwrap();
|
||||
let password = get_password(&encrypted).unwrap();
|
||||
assert_eq!(password, "secret");
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,9 @@ use std::{
|
||||
use tracing::info;
|
||||
use uuid::Uuid;
|
||||
|
||||
// Module de chiffrement des mots de passe
|
||||
pub mod encryption;
|
||||
|
||||
// Modules conditionnels pour l'API REST
|
||||
#[cfg(feature = "api")]
|
||||
pub mod api;
|
||||
@@ -63,24 +66,6 @@ const DEFAULT_LOG_BUFFER_CAPACITY: usize = 1000;
|
||||
const DEFAULT_LOG_MIN_LEVEL: &str = "TRACE";
|
||||
const DEFAULT_LOG_ENABLE_CONSOLE: bool = true;
|
||||
|
||||
/// Macro to generate getter/setter for String values
|
||||
macro_rules! impl_string_config {
|
||||
($(#[$meta:meta])* $getter:ident, $setter:ident, $path:expr, $default:expr) => {
|
||||
$(#[$meta])*
|
||||
pub fn $getter(&self) -> Result<String> {
|
||||
match self.get_value($path)? {
|
||||
Value::String(s) => Ok(s),
|
||||
_ => Err(anyhow!(concat!(stringify!($getter), " not configured"))),
|
||||
}
|
||||
}
|
||||
|
||||
$(#[$meta])*
|
||||
pub fn $setter(&self, value: &str) -> Result<()> {
|
||||
self.set_value($path, Value::String(value.to_string()))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// Macro to generate getter/setter for usize values with default
|
||||
macro_rules! impl_usize_config {
|
||||
($getter:ident, $setter:ident, $path:expr, $default:expr) => {
|
||||
@@ -606,37 +591,6 @@ impl Config {
|
||||
self.set_value(&["devices", devtype, name, "udn"], Value::String(sanitized))
|
||||
}
|
||||
|
||||
impl_string_config!(
|
||||
/// Gets the Qobuz username from configuration
|
||||
get_qobuz_username,
|
||||
set_qobuz_username,
|
||||
&["accounts", "qobuz", "username"],
|
||||
""
|
||||
);
|
||||
|
||||
impl_string_config!(
|
||||
/// Gets the Qobuz password from configuration
|
||||
get_qobuz_password,
|
||||
set_qobuz_password,
|
||||
&["accounts", "qobuz", "password"],
|
||||
""
|
||||
);
|
||||
|
||||
/// Gets the Qobuz credentials (username and password) from configuration
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns a `Result` containing a tuple of (username, password)
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if either username or password is not configured
|
||||
pub fn get_qobuz_credentials(&self) -> Result<(String, String)> {
|
||||
let username = self.get_qobuz_username()?;
|
||||
let password = self.get_qobuz_password()?;
|
||||
Ok((username, password))
|
||||
}
|
||||
|
||||
impl_usize_config!(
|
||||
get_log_cache_size,
|
||||
set_log_cache_size,
|
||||
|
||||
Reference in New Issue
Block a user