feat: bsn-secrets — GPG-verschlüsselter Secrets Manager für alle BSN-Agenten
Quality Gate / 🛡️ Lint + Type Check + Tests (push) Failing after 11m55s
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:
Executable
+189
@@ -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()
|
||||
Reference in New Issue
Block a user