feat: bsn-secrets — GPG-verschlüsselter Secrets Manager für alle BSN-Agenten
Quality Gate / 🛡️ Lint + Type Check + Tests (push) Failing after 11m55s

- Ersetzt fragiles /tmp/cody-gitea-token durch persistente GPG-Verschlüsselung
- Symmetrische GPG-Verschlüsselung (kein Key-Management nötig)
- Kommandos: set, get, list, delete
- Passphrase aus BSN_SECRETS_PASSPHRASE (Hermes .env)
- Skills (alicia-gitea-workflow, article-management) auf bsn-secrets umgestellt
- .gitignore: *.gpg und password-store/ ausgeschlossen
- Verhindert Token-Verlust durch /tmp-Löschung bei Reboots
This commit is contained in:
Hermes Agent
2026-06-22 18:13:40 +02:00
parent bf16015d5c
commit 8f51b8c17c
2 changed files with 191 additions and 0 deletions
+189
View File
@@ -0,0 +1,189 @@
#!/usr/bin/env python3
"""BSN Secrets Manager — GPG-verschlüsselte Secrets für alle BSN-Agenten.
Usage:
bsn-secrets set <name> # Wert von stdin einlesen und verschlüsselt speichern
bsn-secrets get <name> # Entschlüsselten Wert auf stdout ausgeben
bsn-secrets list # Alle gespeicherten Secrets auflisten
bsn-secrets delete <name> # 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 <name>", file=sys.stderr)
sys.exit(1)
cmd_set(sys.argv[2])
elif cmd == "get":
if len(sys.argv) < 3:
print("Usage: bsn-secrets get <name>", 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 <name>", 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()