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:
@@ -36,3 +36,5 @@ media/*
|
|||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
*.gpg
|
||||||
|
password-store/
|
||||||
|
|||||||
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