diff --git a/.gitignore b/.gitignore index 69a4777..b26c797 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,5 @@ media/* # OS .DS_Store Thumbs.db +*.gpg +password-store/ diff --git a/tools/bsn-secrets b/tools/bsn-secrets new file mode 100755 index 0000000..61833e5 --- /dev/null +++ b/tools/bsn-secrets @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +"""BSN Secrets Manager — GPG-verschlüsselte Secrets für alle BSN-Agenten. + +Usage: + bsn-secrets set # Wert von stdin einlesen und verschlüsselt speichern + bsn-secrets get # Entschlüsselten Wert auf stdout ausgeben + bsn-secrets list # Alle gespeicherten Secrets auflisten + bsn-secrets delete # Secret löschen + +Konfiguration: + Passphrase aus BSN_SECRETS_PASSPHRASE (aus Hermes .env) + Store-Verzeichnis: ~/.password-store/bsn/ + +Beispiele: + echo "abc123token" | bsn-secrets set gitea-token + bsn-secrets get gitea-token + TOKEN=$(bsn-secrets get gitea-token) +""" + +import os +import sys +import subprocess +import json +from pathlib import Path + +STORE = Path.home() / ".password-store" / "bsn" + + +def _get_passphrase() -> str: + """Passphrase aus Umgebungsvariable oder .env-Dateien lesen.""" + pp = os.getenv("BSN_SECRETS_PASSPHRASE", "") + if pp: + return pp + + # Fallback: Aus Hermes .env lesen + for env_path in [ + Path.home() / ".hermes" / "profiles" / "cody" / ".env", + Path.home() / ".hermes" / ".env", + ]: + if env_path.exists(): + for line in env_path.read_text().split("\n"): + if line.startswith("BSN_SECRETS_PASSPHRASE="): + return line.split("=", 1)[1].strip().strip('"').strip("'") + + return "" + + +def _encrypt(plaintext: str, passphrase: str) -> bytes: + """GPG-symmetrisch verschlüsseln.""" + result = subprocess.run( + [ + "gpg", "--symmetric", "--batch", "--yes", + "--passphrase", passphrase, + "--quiet", "--no-tty", + ], + input=plaintext.encode("utf-8"), + capture_output=True, + ) + if result.returncode != 0: + print(f"❌ GPG-Verschlüsselung fehlgeschlagen: {result.stderr.decode()}", file=sys.stderr) + sys.exit(1) + return result.stdout + + +def _decrypt(filepath: Path, passphrase: str) -> str: + """GPG-symmetrisch entschlüsseln.""" + result = subprocess.run( + [ + "gpg", "--decrypt", "--batch", "--yes", + "--passphrase", passphrase, + "--quiet", "--no-tty", + str(filepath), + ], + capture_output=True, + ) + if result.returncode != 0: + print(f"❌ GPG-Entschlüsselung fehlgeschlagen: {result.stderr.decode()}", file=sys.stderr) + sys.exit(1) + return result.stdout.decode("utf-8").strip() + + +def _resolve_path(name: str) -> Path: + """Namen in Dateipfad auflösen. 'foo/bar' → STORE/foo/bar.gpg, 'bar' → STORE/bar.gpg.""" + parts = name.split("/") + parent = STORE.joinpath(*parts[:-1]) if len(parts) > 1 else STORE + parent.mkdir(parents=True, exist_ok=True) + return parent / f"{parts[-1]}.gpg" + + +def cmd_set(name: str) -> None: + """Secret von stdin lesen und verschlüsselt speichern.""" + passphrase = _get_passphrase() + if not passphrase: + print("❌ BSN_SECRETS_PASSPHRASE nicht gesetzt!", file=sys.stderr) + sys.exit(1) + + plaintext = sys.stdin.read().strip() + if not plaintext: + print("❌ Kein Input auf stdin!", file=sys.stderr) + sys.exit(1) + + encrypted = _encrypt(plaintext, passphrase) + filepath = _resolve_path(name) + filepath.write_bytes(encrypted) + filepath.chmod(0o600) + print(f"✅ Secret '{name}' gespeichert ({len(plaintext)} Zeichen)") + + +def cmd_get(name: str) -> None: + """Secret entschlüsseln und auf stdout ausgeben.""" + passphrase = _get_passphrase() + if not passphrase: + print("❌ BSN_SECRETS_PASSPHRASE nicht gesetzt!", file=sys.stderr) + sys.exit(1) + + filepath = _resolve_path(name) + if not filepath.exists(): + print(f"❌ Secret '{name}' nicht gefunden!", file=sys.stderr) + sys.exit(1) + + plaintext = _decrypt(filepath, passphrase) + sys.stdout.write(plaintext) + + +def cmd_list() -> None: + """Alle gespeicherten Secrets auflisten.""" + if not STORE.exists(): + print("(keine Secrets gespeichert)") + return + + secrets = sorted(STORE.rglob("*.gpg")) + if not secrets: + print("(keine Secrets gespeichert)") + return + + for f in secrets: + rel = f.relative_to(STORE) + name = str(rel.with_suffix("")) # Ohne .gpg + size = f.stat().st_size + print(f" 🔒 {name:30s} ({size} bytes)") + + +def cmd_delete(name: str) -> None: + """Secret löschen.""" + filepath = _resolve_path(name) + if not filepath.exists(): + print(f"❌ Secret '{name}' nicht gefunden!", file=sys.stderr) + sys.exit(1) + + filepath.unlink() + print(f"🗑️ Secret '{name}' gelöscht") + + +def main(): + if len(sys.argv) < 2: + print(__doc__) + sys.exit(0) + + cmd = sys.argv[1] + + if cmd == "set": + if len(sys.argv) < 3: + print("Usage: bsn-secrets set ", file=sys.stderr) + sys.exit(1) + cmd_set(sys.argv[2]) + + elif cmd == "get": + if len(sys.argv) < 3: + print("Usage: bsn-secrets get ", file=sys.stderr) + sys.exit(1) + cmd_get(sys.argv[2]) + + elif cmd == "list": + cmd_list() + + elif cmd == "delete": + if len(sys.argv) < 3: + print("Usage: bsn-secrets delete ", file=sys.stderr) + sys.exit(1) + cmd_delete(sys.argv[2]) + + else: + print(f"❌ Unbekannter Befehl: {cmd}", file=sys.stderr) + print(__doc__) + sys.exit(1) + + +if __name__ == "__main__": + main()