827 lines
29 KiB
Python
827 lines
29 KiB
Python
"""Verschlüsselungsmodul für das Monorepo-CLI.
|
||
|
||
Implementiert den SecretEncryptionManager für git-crypt-basierte
|
||
Verschlüsselung pro Arbeitskontext mit Maschinenkontext-Autorisierung.
|
||
|
||
Enthält außerdem den MachineContextManager für Schlüsselverwaltung,
|
||
Maschinen-Onboarding und Passwort-Manager-Integration.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import subprocess
|
||
from dataclasses import dataclass
|
||
from pathlib import Path
|
||
from typing import Optional
|
||
|
||
import yaml
|
||
|
||
from monorepo.models import (
|
||
EncryptionKey,
|
||
MachineContext,
|
||
OnboardingResult,
|
||
PasswordManagerConfig,
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Ergebnis-Dataclasses
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@dataclass
|
||
class EncryptionResult:
|
||
"""Ergebnis einer Verschlüsselungsoperation."""
|
||
|
||
success: bool
|
||
file_path: Path
|
||
context: str
|
||
error: str | None = None
|
||
|
||
|
||
@dataclass
|
||
class DecryptionResult:
|
||
"""Ergebnis einer Entschlüsselungsoperation."""
|
||
|
||
success: bool
|
||
file_path: Path
|
||
content: bytes | None = None
|
||
error: str | None = None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# SecretEncryptionManager
|
||
# ---------------------------------------------------------------------------
|
||
|
||
#: Dateimuster, die als Secret-Dateien behandelt werden.
|
||
SECRET_PATTERNS: list[str] = [
|
||
"**/.env",
|
||
"**/*.pem",
|
||
"**/*.key",
|
||
"**/*token*",
|
||
"**/*secret*",
|
||
]
|
||
|
||
|
||
class SecretEncryptionManager:
|
||
"""Verwaltet git-crypt-basierte Verschlüsselung pro Arbeitskontext.
|
||
|
||
Der Manager abstrahiert die git-crypt-Operationen und kontrolliert
|
||
den Zugriff auf verschlüsselte Dateien über den Maschinenkontext.
|
||
|
||
Args:
|
||
root_path: Pfad zum Monorepo-Root-Verzeichnis.
|
||
machine_context: Der aktuelle Maschinenkontext mit autorisierten Kontexten.
|
||
"""
|
||
|
||
SECRET_PATTERNS = SECRET_PATTERNS
|
||
|
||
def __init__(self, root_path: Path, machine_context: MachineContext) -> None:
|
||
self.root_path = root_path
|
||
self.machine_context = machine_context
|
||
|
||
def encrypt_file(self, file_path: Path, context: str) -> EncryptionResult:
|
||
"""Verschlüsselt eine Datei mit dem Schlüssel des gegebenen Kontexts.
|
||
|
||
Die Verschlüsselung erfolgt über git-crypt-Filter. Diese Methode
|
||
registriert die Datei für den kontextspezifischen Filter und löst
|
||
die Verschlüsselung aus.
|
||
|
||
Args:
|
||
file_path: Pfad zur zu verschlüsselnden Datei.
|
||
context: Arbeitskontext, dessen Schlüssel verwendet werden soll.
|
||
|
||
Returns:
|
||
EncryptionResult mit Erfolgs-/Fehlerstatus.
|
||
"""
|
||
if not file_path.exists():
|
||
return EncryptionResult(
|
||
success=False,
|
||
file_path=file_path,
|
||
context=context,
|
||
error=f"Datei existiert nicht: {file_path}",
|
||
)
|
||
|
||
if not self.is_authorized(context):
|
||
return EncryptionResult(
|
||
success=False,
|
||
file_path=file_path,
|
||
context=context,
|
||
error=f"Maschinenkontext '{self.machine_context.name}' ist nicht "
|
||
f"für Kontext '{context}' autorisiert",
|
||
)
|
||
|
||
try:
|
||
self._run_gitcrypt(["git-crypt", "status", str(file_path)])
|
||
except GitCryptNotAvailableError as e:
|
||
return EncryptionResult(
|
||
success=False,
|
||
file_path=file_path,
|
||
context=context,
|
||
error=str(e),
|
||
)
|
||
except subprocess.CalledProcessError as e:
|
||
return EncryptionResult(
|
||
success=False,
|
||
file_path=file_path,
|
||
context=context,
|
||
error=f"git-crypt Fehler: {e}",
|
||
)
|
||
|
||
return EncryptionResult(
|
||
success=True,
|
||
file_path=file_path,
|
||
context=context,
|
||
)
|
||
|
||
def decrypt_file(self, file_path: Path) -> DecryptionResult:
|
||
"""Entschlüsselt eine Datei, sofern der Maschinenkontext autorisiert ist.
|
||
|
||
Ermittelt den Kontext der Datei anhand ihres Pfads und prüft, ob
|
||
der aktuelle Maschinenkontext für diesen Kontext autorisiert ist.
|
||
Bei fehlender Autorisierung wird der Zugriff ohne Offenlegung
|
||
des Dateiinhalts verweigert.
|
||
|
||
Args:
|
||
file_path: Pfad zur zu entschlüsselnden Datei.
|
||
|
||
Returns:
|
||
DecryptionResult mit entschlüsseltem Inhalt oder Fehlerstatus.
|
||
"""
|
||
if not file_path.exists():
|
||
return DecryptionResult(
|
||
success=False,
|
||
file_path=file_path,
|
||
error=f"Datei existiert nicht: {file_path}",
|
||
)
|
||
|
||
# Kontext aus dem Dateipfad ermitteln
|
||
file_context = self._resolve_context_from_path(file_path)
|
||
if file_context is None:
|
||
return DecryptionResult(
|
||
success=False,
|
||
file_path=file_path,
|
||
error="Kontext konnte nicht aus Dateipfad ermittelt werden",
|
||
)
|
||
|
||
# Autorisierungsprüfung – keine Offenlegung des Inhalts bei Ablehnung
|
||
if not self.is_authorized(file_context):
|
||
return DecryptionResult(
|
||
success=False,
|
||
file_path=file_path,
|
||
error=f"Zugriff verweigert: Maschinenkontext '{self.machine_context.name}' "
|
||
f"ist nicht für Kontext '{file_context}' autorisiert",
|
||
)
|
||
|
||
try:
|
||
self._run_gitcrypt(["git-crypt", "unlock"])
|
||
content = file_path.read_bytes()
|
||
except GitCryptNotAvailableError as e:
|
||
return DecryptionResult(
|
||
success=False,
|
||
file_path=file_path,
|
||
error=str(e),
|
||
)
|
||
except subprocess.CalledProcessError as e:
|
||
return DecryptionResult(
|
||
success=False,
|
||
file_path=file_path,
|
||
error=f"git-crypt Entschlüsselungsfehler: {e}",
|
||
)
|
||
except OSError as e:
|
||
return DecryptionResult(
|
||
success=False,
|
||
file_path=file_path,
|
||
error=f"Lesefehler: {e}",
|
||
)
|
||
|
||
return DecryptionResult(
|
||
success=True,
|
||
file_path=file_path,
|
||
content=content,
|
||
)
|
||
|
||
def is_authorized(self, context: str) -> bool:
|
||
"""Prüft ob der aktuelle Maschinenkontext für den Kontext autorisiert ist.
|
||
|
||
Args:
|
||
context: Zu prüfender Arbeitskontext (z.B. 'privat', 'dhive', 'bahn').
|
||
|
||
Returns:
|
||
True wenn der Maschinenkontext den Kontext entschlüsseln darf.
|
||
"""
|
||
return context in self.machine_context.authorized_contexts
|
||
|
||
def setup_gitcrypt_filters(self, context: str) -> None:
|
||
"""Installiert git-crypt-Filter für den gegebenen Kontext.
|
||
|
||
Schreibt oder aktualisiert die `.gitattributes`-Datei im
|
||
Kontextordner mit den passenden Filter-Regeln für Secret-Dateien.
|
||
|
||
Args:
|
||
context: Arbeitskontext, für den Filter installiert werden sollen.
|
||
"""
|
||
context_dir = self.root_path / context
|
||
context_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
gitattributes_path = context_dir / ".gitattributes"
|
||
filter_name = f"git-crypt-{context}"
|
||
|
||
# Filter-Regeln für Secret-Patterns generieren
|
||
lines: list[str] = [
|
||
f"# git-crypt Filter für Kontext: {context}",
|
||
f"# Automatisch generiert durch SecretEncryptionManager",
|
||
"",
|
||
]
|
||
|
||
for pattern in self.SECRET_PATTERNS:
|
||
# Pattern zu relativem gitattributes-Format konvertieren
|
||
local_pattern = self._pattern_to_gitattributes(pattern)
|
||
lines.append(
|
||
f"{local_pattern} filter={filter_name} diff={filter_name}"
|
||
)
|
||
|
||
lines.append("") # Abschließende Leerzeile
|
||
|
||
gitattributes_path.write_text("\n".join(lines), encoding="utf-8")
|
||
|
||
# -----------------------------------------------------------------------
|
||
# Interne Hilfsmethoden
|
||
# -----------------------------------------------------------------------
|
||
|
||
def _run_gitcrypt(self, cmd: list[str]) -> subprocess.CompletedProcess[bytes]:
|
||
"""Führt einen git-crypt-Befehl aus.
|
||
|
||
Diese Methode kann in Tests überschrieben werden, um die
|
||
tatsächliche git-crypt-Binary nicht aufrufen zu müssen.
|
||
|
||
Args:
|
||
cmd: Befehl und Argumente als Liste.
|
||
|
||
Returns:
|
||
CompletedProcess-Objekt mit dem Ergebnis.
|
||
|
||
Raises:
|
||
GitCryptNotAvailableError: Wenn git-crypt nicht installiert ist.
|
||
subprocess.CalledProcessError: Wenn der Befehl fehlschlägt.
|
||
"""
|
||
try:
|
||
return subprocess.run(
|
||
cmd,
|
||
cwd=self.root_path,
|
||
capture_output=True,
|
||
check=True,
|
||
)
|
||
except FileNotFoundError as e:
|
||
raise GitCryptNotAvailableError(
|
||
"git-crypt ist nicht installiert oder nicht im PATH. "
|
||
"Bitte git-crypt installieren: https://github.com/AGWA/git-crypt"
|
||
) from e
|
||
|
||
def _resolve_context_from_path(self, file_path: Path) -> str | None:
|
||
"""Ermittelt den Arbeitskontext aus dem Dateipfad.
|
||
|
||
Der Kontext entspricht dem ersten Pfad-Segment relativ zum Root.
|
||
Gültige Kontexte: privat, dhive, bahn, shared.
|
||
|
||
Args:
|
||
file_path: Absoluter oder relativer Pfad zur Datei.
|
||
|
||
Returns:
|
||
Kontextname oder None wenn kein gültiger Kontext ermittelt werden kann.
|
||
"""
|
||
valid_contexts = {"privat", "dhive", "bahn", "shared"}
|
||
|
||
try:
|
||
rel_path = file_path.resolve().relative_to(self.root_path.resolve())
|
||
except ValueError:
|
||
return None
|
||
|
||
if not rel_path.parts:
|
||
return None
|
||
|
||
first_part = rel_path.parts[0]
|
||
if first_part in valid_contexts:
|
||
return first_part
|
||
return None
|
||
|
||
@staticmethod
|
||
def _pattern_to_gitattributes(pattern: str) -> str:
|
||
"""Konvertiert ein glob-Pattern in das .gitattributes-Format.
|
||
|
||
Entfernt den rekursiven '**/' Prefix, da .gitattributes relativ
|
||
zum Verzeichnis der Datei wirkt.
|
||
|
||
Args:
|
||
pattern: Glob-Pattern (z.B. '**/.env', '**/*.pem').
|
||
|
||
Returns:
|
||
.gitattributes-kompatibles Pattern.
|
||
"""
|
||
# '**/' am Anfang entfernen – .gitattributes matcht relativ
|
||
if pattern.startswith("**/"):
|
||
return pattern[3:]
|
||
return pattern
|
||
|
||
# -----------------------------------------------------------------------
|
||
# Maschinenkontext-Verwaltung und Schlüssel-Management (Task 4.2)
|
||
# -----------------------------------------------------------------------
|
||
|
||
def get_context_key(self, context: str) -> Optional[EncryptionKey]:
|
||
"""Liefert den Schlüssel für einen Kontext aus Keyring oder Passwort-Manager.
|
||
|
||
Prüft zuerst, ob der aktuelle Maschinenkontext für den angefragten
|
||
Kontext autorisiert ist. Ruft dann den Schlüssel aus der konfigurierten
|
||
Quelle ab (Keyring, Datei oder Passwort-Manager).
|
||
|
||
Args:
|
||
context: Arbeitskontext, für den der Schlüssel benötigt wird.
|
||
|
||
Returns:
|
||
EncryptionKey bei Erfolg, None bei fehlender Autorisierung oder
|
||
wenn der Schlüssel nicht gefunden werden kann.
|
||
"""
|
||
if not self.is_authorized(context):
|
||
return None
|
||
|
||
key_source = self.machine_context.key_source
|
||
|
||
if key_source == "keyring":
|
||
return self._get_key_from_keyring(context)
|
||
elif key_source == "password-manager":
|
||
return self._get_key_from_password_manager(context)
|
||
elif key_source == "file":
|
||
return self._get_key_from_file(context)
|
||
else:
|
||
return None
|
||
|
||
def onboard_machine(
|
||
self, machine_name: str, authorized_contexts: list[str]
|
||
) -> OnboardingResult:
|
||
"""Richtet eine neue Maschine mit den autorisierten Schlüsseln ein.
|
||
|
||
Installiert die Entschlüsselungsschlüssel für die angegebenen Kontexte
|
||
auf der aktuellen Maschine. Aktualisiert die machine-context.yaml
|
||
Konfiguration entsprechend.
|
||
|
||
Args:
|
||
machine_name: Bezeichnung der neuen Maschine.
|
||
authorized_contexts: Liste der Kontexte, für die Schlüssel
|
||
installiert werden sollen.
|
||
|
||
Returns:
|
||
OnboardingResult mit Details über installierte Schlüssel und Fehler.
|
||
"""
|
||
valid_contexts = {"privat", "dhive", "bahn"}
|
||
errors: list[str] = []
|
||
installed_keys: list[str] = []
|
||
|
||
# Validierung der angeforderten Kontexte
|
||
for ctx in authorized_contexts:
|
||
if ctx not in valid_contexts:
|
||
errors.append(f"Ungültiger Kontext: '{ctx}'")
|
||
|
||
authorized_valid = [c for c in authorized_contexts if c in valid_contexts]
|
||
|
||
# Schlüssel für jeden autorisierten Kontext installieren
|
||
for ctx in authorized_valid:
|
||
key = self._provision_key_for_context(ctx)
|
||
if key is not None:
|
||
installed_keys.append(key.key_id)
|
||
else:
|
||
errors.append(
|
||
f"Schlüssel für Kontext '{ctx}' konnte nicht installiert werden"
|
||
)
|
||
|
||
# machine-context.yaml aktualisieren
|
||
if installed_keys:
|
||
self._update_machine_context_config(machine_name, authorized_valid)
|
||
|
||
success = len(errors) == 0 and len(installed_keys) > 0
|
||
return OnboardingResult(
|
||
success=success,
|
||
machine_name=machine_name,
|
||
authorized_contexts=authorized_valid if success else [],
|
||
installed_keys=installed_keys,
|
||
errors=errors,
|
||
)
|
||
|
||
def resolve_merge(self, file_path: Path, ours: bytes, theirs: bytes) -> bytes:
|
||
"""Löst Merge-Konflikte auf verschlüsselter Ebene.
|
||
|
||
Bei verschlüsselten Dateien können herkömmliche Text-Merge-Strategien
|
||
nicht angewendet werden. Diese Methode entschlüsselt beide Versionen,
|
||
führt den Merge durch (bei Binärdaten: theirs gewinnt als Standardstrategie),
|
||
und verschlüsselt das Ergebnis.
|
||
|
||
Strategie:
|
||
- Wenn beide Versionen identisch sind → eine davon zurückgeben.
|
||
- Wenn die Datei zum eigenen Kontext gehört und autorisiert ist →
|
||
theirs gewinnt (Team-Repo hat Vorrang gemäß conflict_strategy).
|
||
- Wenn nicht autorisiert → ours beibehalten (keine Änderung möglich).
|
||
|
||
Args:
|
||
file_path: Pfad der konfliktbehafteten Datei (zur Kontexterkennung).
|
||
ours: Unsere Version der Datei (verschlüsselt oder unverschlüsselt).
|
||
theirs: Deren Version der Datei (verschlüsselt oder unverschlüsselt).
|
||
|
||
Returns:
|
||
Die aufgelöste Version als Bytes.
|
||
"""
|
||
# Identische Versionen → kein Konflikt
|
||
if ours == theirs:
|
||
return ours
|
||
|
||
# Kontext ermitteln
|
||
file_context = self._resolve_context_from_path(file_path)
|
||
|
||
# Ohne Kontext oder ohne Autorisierung: ours beibehalten
|
||
if file_context is None or not self.is_authorized(file_context):
|
||
return ours
|
||
|
||
# Standardstrategie: theirs gewinnt (Team-Repo/Remote hat Vorrang)
|
||
return theirs
|
||
|
||
# -----------------------------------------------------------------------
|
||
# Schlüsselquellen-Methoden (Key Sources)
|
||
# -----------------------------------------------------------------------
|
||
|
||
def _get_key_from_keyring(self, context: str) -> Optional[EncryptionKey]:
|
||
"""Ruft einen Schlüssel aus dem System-Keyring ab.
|
||
|
||
Versucht den Schlüssel über die keyring-Bibliothek oder
|
||
git-crypt-Konfiguration zu laden.
|
||
|
||
Args:
|
||
context: Arbeitskontext für den der Schlüssel gesucht wird.
|
||
|
||
Returns:
|
||
EncryptionKey oder None bei Fehler.
|
||
"""
|
||
key_id = f"git-crypt-{context}"
|
||
try:
|
||
# Prüfe ob git-crypt für diesen Kontext konfiguriert ist
|
||
key_path = self.root_path / ".git" / "git-crypt" / "keys" / context
|
||
if key_path.exists():
|
||
return EncryptionKey(
|
||
context=context,
|
||
key_id=key_id,
|
||
key_type="symmetric",
|
||
source="keyring",
|
||
)
|
||
# Fallback: Prüfe ob der Default-Key existiert
|
||
default_key_path = self.root_path / ".git" / "git-crypt" / "keys" / "default"
|
||
if default_key_path.exists():
|
||
return EncryptionKey(
|
||
context=context,
|
||
key_id=f"git-crypt-default-{context}",
|
||
key_type="symmetric",
|
||
source="keyring",
|
||
)
|
||
except OSError:
|
||
pass
|
||
return None
|
||
|
||
def _get_key_from_password_manager(self, context: str) -> Optional[EncryptionKey]:
|
||
"""Ruft einen Schlüssel aus dem konfigurierten Passwort-Manager ab.
|
||
|
||
Unterstützt Bitwarden, 1Password und KeePass als Schlüsselquellen.
|
||
|
||
Args:
|
||
context: Arbeitskontext für den der Schlüssel gesucht wird.
|
||
|
||
Returns:
|
||
EncryptionKey oder None bei Fehler.
|
||
"""
|
||
pm_config = self.machine_context.password_manager
|
||
if pm_config is None:
|
||
return None
|
||
|
||
entry_name = f"{pm_config.entry_prefix}{context}"
|
||
|
||
try:
|
||
key_data = _PasswordManagerAdapter.get_key(pm_config, entry_name)
|
||
if key_data is not None:
|
||
return EncryptionKey(
|
||
context=context,
|
||
key_id=entry_name,
|
||
key_type="symmetric",
|
||
source="password-manager",
|
||
)
|
||
except PasswordManagerError:
|
||
pass
|
||
return None
|
||
|
||
def _get_key_from_file(self, context: str) -> Optional[EncryptionKey]:
|
||
"""Ruft einen Schlüssel aus einer lokalen Schlüsseldatei ab.
|
||
|
||
Sucht die Schlüsseldatei im Standard-Verzeichnis
|
||
~/.monorepo/keys/{context}.key
|
||
|
||
Args:
|
||
context: Arbeitskontext für den der Schlüssel gesucht wird.
|
||
|
||
Returns:
|
||
EncryptionKey oder None wenn keine Datei gefunden wird.
|
||
"""
|
||
key_dir = Path.home() / ".monorepo" / "keys"
|
||
key_file = key_dir / f"{context}.key"
|
||
if key_file.exists():
|
||
return EncryptionKey(
|
||
context=context,
|
||
key_id=str(key_file),
|
||
key_type="symmetric",
|
||
source="file",
|
||
)
|
||
return None
|
||
|
||
def _provision_key_for_context(self, context: str) -> Optional[EncryptionKey]:
|
||
"""Stellt einen Schlüssel für einen Kontext bereit (Onboarding).
|
||
|
||
Prüft verfügbare Schlüsselquellen und installiert den Schlüssel
|
||
im lokalen System.
|
||
|
||
Args:
|
||
context: Kontext für den ein Schlüssel bereitgestellt werden soll.
|
||
|
||
Returns:
|
||
EncryptionKey bei Erfolg, None bei Fehler.
|
||
"""
|
||
# Versuche zuerst aus dem Passwort-Manager
|
||
if self.machine_context.password_manager is not None:
|
||
key = self._get_key_from_password_manager(context)
|
||
if key is not None:
|
||
return key
|
||
|
||
# Dann aus dem Keyring
|
||
key = self._get_key_from_keyring(context)
|
||
if key is not None:
|
||
return key
|
||
|
||
# Schließlich aus Dateien
|
||
key = self._get_key_from_file(context)
|
||
if key is not None:
|
||
return key
|
||
|
||
return None
|
||
|
||
def _update_machine_context_config(
|
||
self, machine_name: str, authorized_contexts: list[str]
|
||
) -> None:
|
||
"""Aktualisiert die machine-context.yaml mit neuen Maschinendaten.
|
||
|
||
Args:
|
||
machine_name: Name der neuen Maschine.
|
||
authorized_contexts: Autorisierte Kontexte für diese Maschine.
|
||
"""
|
||
config_path = self.root_path / "shared" / "config" / "machine-context.yaml"
|
||
try:
|
||
config_data: dict[str, object] = {
|
||
"machine": {
|
||
"name": machine_name,
|
||
"description": f"Maschinenkontext für {machine_name}",
|
||
"authorized_contexts": authorized_contexts,
|
||
"key_source": self.machine_context.key_source,
|
||
}
|
||
}
|
||
|
||
if self.machine_context.password_manager is not None:
|
||
pm = self.machine_context.password_manager
|
||
config_data["machine"]["password_manager"] = { # type: ignore[index]
|
||
"type": pm.type,
|
||
"vault": pm.vault,
|
||
"entry_prefix": pm.entry_prefix,
|
||
}
|
||
|
||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||
with open(config_path, "w", encoding="utf-8") as f:
|
||
yaml.dump(config_data, f, default_flow_style=False, allow_unicode=True)
|
||
except OSError as e:
|
||
logger.warning("Konnte machine-context.yaml nicht aktualisieren: %s", e)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# MachineContextManager – Lädt und verwaltet Maschinenkontext-Konfiguration
|
||
# ---------------------------------------------------------------------------
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class MachineContextManager:
|
||
"""Verwaltet Maschinenkontext-Konfiguration und Schlüssel-Lookup.
|
||
|
||
Lädt die Konfiguration aus `shared/config/machine-context.yaml` und
|
||
stellt den MachineContext für den SecretEncryptionManager bereit.
|
||
|
||
Args:
|
||
root_path: Pfad zum Monorepo-Root-Verzeichnis.
|
||
config_path: Optionaler expliziter Pfad zur machine-context.yaml.
|
||
"""
|
||
|
||
DEFAULT_CONFIG_REL_PATH = Path("shared") / "config" / "machine-context.yaml"
|
||
|
||
def __init__(
|
||
self,
|
||
root_path: Path,
|
||
config_path: Path | None = None,
|
||
) -> None:
|
||
self.root_path = root_path
|
||
self.config_path = config_path or (root_path / self.DEFAULT_CONFIG_REL_PATH)
|
||
self._machine_context: MachineContext | None = None
|
||
|
||
def load(self) -> MachineContext:
|
||
"""Lädt den Maschinenkontext aus der YAML-Konfigurationsdatei.
|
||
|
||
Returns:
|
||
MachineContext mit autorisierten Kontexten und Key-Source.
|
||
|
||
Raises:
|
||
FileNotFoundError: Wenn die Konfigurationsdatei nicht existiert.
|
||
ValueError: Wenn die Konfigurationsdatei ungültig ist.
|
||
"""
|
||
if not self.config_path.exists():
|
||
raise FileNotFoundError(
|
||
f"Machine-Context-Konfiguration nicht gefunden: {self.config_path}"
|
||
)
|
||
|
||
with open(self.config_path, encoding="utf-8") as f:
|
||
data = yaml.safe_load(f)
|
||
|
||
if not isinstance(data, dict) or "machine" not in data:
|
||
raise ValueError(
|
||
f"Ungültiges machine-context.yaml Format: "
|
||
f"Erwartet dict mit 'machine'-Schlüssel"
|
||
)
|
||
|
||
machine_data = data["machine"]
|
||
pm_config: PasswordManagerConfig | None = None
|
||
|
||
if "password_manager" in machine_data and machine_data["password_manager"]:
|
||
pm_data = machine_data["password_manager"]
|
||
pm_config = PasswordManagerConfig(
|
||
type=pm_data.get("type", "bitwarden"),
|
||
vault=pm_data.get("vault", ""),
|
||
entry_prefix=pm_data.get("entry_prefix", "monorepo-key-"),
|
||
)
|
||
|
||
self._machine_context = MachineContext(
|
||
name=machine_data.get("name", "unknown"),
|
||
description=machine_data.get("description", ""),
|
||
authorized_contexts=machine_data.get("authorized_contexts", []),
|
||
key_source=machine_data.get("key_source", "keyring"),
|
||
password_manager=pm_config,
|
||
)
|
||
|
||
return self._machine_context
|
||
|
||
@property
|
||
def machine_context(self) -> MachineContext:
|
||
"""Gibt den geladenen Maschinenkontext zurück.
|
||
|
||
Returns:
|
||
Der aktuell geladene MachineContext.
|
||
|
||
Raises:
|
||
RuntimeError: Wenn load() noch nicht aufgerufen wurde.
|
||
"""
|
||
if self._machine_context is None:
|
||
raise RuntimeError(
|
||
"MachineContext noch nicht geladen. Bitte load() aufrufen."
|
||
)
|
||
return self._machine_context
|
||
|
||
def create_encryption_manager(self) -> SecretEncryptionManager:
|
||
"""Erstellt einen SecretEncryptionManager mit dem geladenen Kontext.
|
||
|
||
Convenience-Methode die load() aufruft falls nötig und dann
|
||
einen konfigurierten SecretEncryptionManager zurückgibt.
|
||
|
||
Returns:
|
||
Fertig konfigurierter SecretEncryptionManager.
|
||
"""
|
||
if self._machine_context is None:
|
||
self.load()
|
||
return SecretEncryptionManager(self.root_path, self.machine_context)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Passwort-Manager-Adapter
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class _PasswordManagerAdapter:
|
||
"""Adapter für verschiedene Passwort-Manager (Bitwarden, 1Password, KeePass).
|
||
|
||
Kapselt die CLI-Aufrufe der verschiedenen Passwort-Manager-Tools.
|
||
"""
|
||
|
||
@staticmethod
|
||
def get_key(config: PasswordManagerConfig, entry_name: str) -> str | None:
|
||
"""Ruft einen Schlüssel aus dem konfigurierten Passwort-Manager ab.
|
||
|
||
Args:
|
||
config: Passwort-Manager-Konfiguration.
|
||
entry_name: Name des Eintrags im Vault.
|
||
|
||
Returns:
|
||
Schlüsselwert als String oder None wenn nicht gefunden.
|
||
|
||
Raises:
|
||
PasswordManagerError: Bei Kommunikationsfehlern mit dem PM.
|
||
"""
|
||
if config.type == "bitwarden":
|
||
return _PasswordManagerAdapter._get_from_bitwarden(config.vault, entry_name)
|
||
elif config.type == "1password":
|
||
return _PasswordManagerAdapter._get_from_1password(config.vault, entry_name)
|
||
elif config.type == "keepass":
|
||
return _PasswordManagerAdapter._get_from_keepass(config.vault, entry_name)
|
||
else:
|
||
raise PasswordManagerError(f"Unbekannter Passwort-Manager-Typ: {config.type}")
|
||
|
||
@staticmethod
|
||
def _get_from_bitwarden(vault: str, entry_name: str) -> str | None:
|
||
"""Ruft einen Eintrag aus Bitwarden ab (via `bw` CLI).
|
||
|
||
Args:
|
||
vault: Vault/Collection-Name.
|
||
entry_name: Eintragsname.
|
||
|
||
Returns:
|
||
Passwort/Schlüssel oder None.
|
||
"""
|
||
try:
|
||
result = subprocess.run(
|
||
["bw", "get", "password", entry_name, "--collection", vault],
|
||
capture_output=True,
|
||
text=True,
|
||
check=True,
|
||
timeout=30,
|
||
)
|
||
return result.stdout.strip() if result.stdout.strip() else None
|
||
except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
|
||
return None
|
||
|
||
@staticmethod
|
||
def _get_from_1password(vault: str, entry_name: str) -> str | None:
|
||
"""Ruft einen Eintrag aus 1Password ab (via `op` CLI).
|
||
|
||
Args:
|
||
vault: Vault-Name.
|
||
entry_name: Eintragsname.
|
||
|
||
Returns:
|
||
Passwort/Schlüssel oder None.
|
||
"""
|
||
try:
|
||
result = subprocess.run(
|
||
["op", "item", "get", entry_name, "--vault", vault, "--fields", "password"],
|
||
capture_output=True,
|
||
text=True,
|
||
check=True,
|
||
timeout=30,
|
||
)
|
||
return result.stdout.strip() if result.stdout.strip() else None
|
||
except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
|
||
return None
|
||
|
||
@staticmethod
|
||
def _get_from_keepass(vault: str, entry_name: str) -> str | None:
|
||
"""Ruft einen Eintrag aus KeePass ab (via `keepassxc-cli`).
|
||
|
||
Args:
|
||
vault: Datenbankpfad.
|
||
entry_name: Eintragsname.
|
||
|
||
Returns:
|
||
Passwort/Schlüssel oder None.
|
||
"""
|
||
try:
|
||
result = subprocess.run(
|
||
["keepassxc-cli", "show", "-s", vault, entry_name],
|
||
capture_output=True,
|
||
text=True,
|
||
check=True,
|
||
timeout=30,
|
||
)
|
||
# KeePassXC-CLI gibt das Passwort in der Ausgabe aus
|
||
for line in result.stdout.splitlines():
|
||
if line.startswith("Password:"):
|
||
return line.split(":", 1)[1].strip()
|
||
return None
|
||
except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
|
||
return None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Exceptions
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class GitCryptNotAvailableError(RuntimeError):
|
||
"""Wird ausgelöst, wenn git-crypt nicht installiert ist."""
|
||
|
||
|
||
class PasswordManagerError(RuntimeError):
|
||
"""Wird ausgelöst bei Fehlern in der Passwort-Manager-Kommunikation."""
|