feat(admin): hemmeligheds-drop til Claude
/admin/secrets tager imod et token eller kodeord og krypterer det med det samme med PVE-værtens offentlige X25519-nøgle (sealed box: HKDF-SHA256 + ChaCha20-Poly1305, navnet som associated data). Kun ciphertext gemmes; portalen kan ikke læse det igen. På værten henter secret-drop get <navn> det én gang og sletter kopien her. Claude-Session: https://claude.ai/code/session_012LCTmU2ewo8tnM1dByYAoG
This commit is contained in:
@@ -104,6 +104,8 @@ ADMIN_MODULES = [
|
||||
"desc": "Slet aldrig-set film & serier automatisk — med karens, behold-liste og ntfy."},
|
||||
{"key": "yt", "title": "YouTube offline", "icon": "📺",
|
||||
"desc": "Hent YouTube-videoer til Emby - og slet dem igen naar de er set."},
|
||||
{"key": "secrets", "title": "Hemmeligheder til Claude", "icon": "🔏",
|
||||
"desc": "Giv et token eller kodeord til serveren krypteret, uden at skrive det i chatten."},
|
||||
{"key": "mcp-keys", "title": "MCP-nøgler", "icon": "🔑",
|
||||
"desc": "Opret og se API-nøgler til alle MCP-servere (crawlkit, Gitea, HA, Paperless, portal)."},
|
||||
]
|
||||
|
||||
@@ -20,6 +20,7 @@ import ytdl as yt
|
||||
import mcpkeys
|
||||
import mcp_server
|
||||
import mcp_gateway
|
||||
import secretdrop
|
||||
|
||||
# Portalens egen MCP (FastMCP), monteret in-process på /mcp. json_response=True
|
||||
# => almindelig JSON (ingen SSE), så det passerer edge'en utunet. Bearer-gated.
|
||||
@@ -324,6 +325,34 @@ async def admin_invitations_delete(request: Request, pk: str):
|
||||
status_code=303)
|
||||
|
||||
|
||||
@app.get("/admin/secrets", response_class=HTMLResponse)
|
||||
async def admin_secrets(request: Request, saved: str = "", gemt: str = ""):
|
||||
user = require_admin(request)
|
||||
return templates.TemplateResponse(request, "admin_secrets.html", {
|
||||
"request": request, "user": user, "is_admin": True,
|
||||
"ventende": secretdrop.pending(), "saved": saved, "gemt": gemt})
|
||||
|
||||
|
||||
@app.post("/admin/secrets")
|
||||
async def admin_secrets_store(request: Request):
|
||||
user = require_admin(request)
|
||||
f = await request.form()
|
||||
navn = (f.get("navn") or "").strip().lower()
|
||||
fejl = secretdrop.store(navn, f.get("vaerdi") or "", f.get("note") or "",
|
||||
(user or {}).get("preferred_username") or (user or {}).get("email") or "?")
|
||||
if fejl:
|
||||
return RedirectResponse(f"/admin/secrets?saved={fejl}", status_code=303)
|
||||
# Værdien sendes aldrig tilbage; kun navnet, så siden kan bekræfte det.
|
||||
return RedirectResponse(f"/admin/secrets?gemt={navn}", status_code=303)
|
||||
|
||||
|
||||
@app.post("/admin/secrets/{navn}/slet")
|
||||
async def admin_secrets_delete(request: Request, navn: str):
|
||||
require_admin(request)
|
||||
ok = secretdrop.delete(navn)
|
||||
return RedirectResponse(f"/admin/secrets?saved={'Slettet' if ok else 'Fandtes ikke'}", status_code=303)
|
||||
|
||||
|
||||
@app.get("/admin/groups", response_class=HTMLResponse)
|
||||
async def admin_groups(request: Request, saved: str = ""):
|
||||
user = require_admin(request)
|
||||
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
"""Hemmeligheds-drop: giv et token eller kodeord til serveren uden om chatten.
|
||||
|
||||
Værdien krypteres med det samme i requesten med PVE-værtens offentlige nøgle
|
||||
og kun den krypterede udgave gemmes. Portalen har ikke den private nøgle, så
|
||||
den kan hverken vise eller læse en gemt hemmelighed bagefter, heller ikke hvis
|
||||
CT151 kompromitteres. På værten henter `secret-drop get <navn>` filen,
|
||||
dekrypterer den og sletter den her (hver hemmelighed kan hentes én gang).
|
||||
|
||||
Format (version 1), samme idé som en libsodium/age "sealed box":
|
||||
b"WSD1" | ephemeral X25519 public key (32) | nonce (12) | ChaCha20-Poly1305
|
||||
nøglen = HKDF-SHA256(X25519(eph, modtager), salt = eph_pub || modtager_pub,
|
||||
info = b"weircon secret-drop v1"); navnet er associated data, så en fil
|
||||
ikke kan omdøbes til en anden hemmelighed uden at dekrypteringen fejler.
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey
|
||||
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
|
||||
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||
|
||||
import config
|
||||
|
||||
DIR = config.DATA / "secret-drop"
|
||||
PUBKEY = config.DATA / "secret-drop.pub"
|
||||
NAVN = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}$")
|
||||
MAKS = 64 * 1024
|
||||
INFO = b"weircon secret-drop v1"
|
||||
|
||||
|
||||
def _recipient() -> X25519PublicKey:
|
||||
raw = base64.b64decode(PUBKEY.read_text().strip())
|
||||
return X25519PublicKey.from_public_bytes(raw)
|
||||
|
||||
|
||||
def seal(name: str, value: bytes) -> bytes:
|
||||
recipient = _recipient()
|
||||
eph = X25519PrivateKey.generate()
|
||||
eph_pub = eph.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw)
|
||||
rcp_pub = recipient.public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw)
|
||||
key = HKDF(algorithm=hashes.SHA256(), length=32, salt=eph_pub + rcp_pub, info=INFO).derive(eph.exchange(recipient))
|
||||
nonce = os.urandom(12)
|
||||
return b"WSD1" + eph_pub + nonce + ChaCha20Poly1305(key).encrypt(nonce, value, name.encode())
|
||||
|
||||
|
||||
def store(name: str, value: str, note: str, by: str) -> str | None:
|
||||
"""Gem en hemmelighed krypteret. Returnerer en fejltekst, eller None."""
|
||||
name = (name or "").strip().lower()
|
||||
if not NAVN.match(name):
|
||||
return "Navnet må kun indeholde små bogstaver, tal, punktum, bindestreg og understreg"
|
||||
data = (value or "").strip().encode()
|
||||
if not data:
|
||||
return "Værdien er tom"
|
||||
if len(data) > MAKS:
|
||||
return "Værdien er for stor (maks 64 KB)"
|
||||
DIR.mkdir(mode=0o700, exist_ok=True)
|
||||
blob = seal(name, data)
|
||||
del data
|
||||
# Skrives atomisk og kun læsbar for portalen; en gammel med samme navn erstattes.
|
||||
for suffix, content, mode in ((".wsd", blob, "wb"),
|
||||
(".json", json.dumps({"name": name, "note": (note or "").strip()[:200],
|
||||
"by": by, "created": int(time.time()),
|
||||
"bytes": len(blob)}, ensure_ascii=False), "w")):
|
||||
tmp = DIR / f".{name}{suffix}.tmp"
|
||||
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, mode) as f:
|
||||
f.write(content)
|
||||
os.replace(tmp, DIR / f"{name}{suffix}")
|
||||
return None
|
||||
|
||||
|
||||
def pending() -> list[dict]:
|
||||
"""Hemmeligheder der venter på at blive hentet (kun metadata)."""
|
||||
if not DIR.exists():
|
||||
return []
|
||||
out = []
|
||||
for meta in sorted(DIR.glob("*.json")):
|
||||
try:
|
||||
m = json.loads(meta.read_text())
|
||||
except ValueError:
|
||||
continue
|
||||
m["alder"] = _alder(int(time.time()) - int(m.get("created", 0)))
|
||||
out.append(m)
|
||||
return sorted(out, key=lambda m: m.get("created", 0), reverse=True)
|
||||
|
||||
|
||||
def delete(name: str) -> bool:
|
||||
if not NAVN.match(name or ""):
|
||||
return False
|
||||
found = False
|
||||
for suffix in (".wsd", ".json"):
|
||||
p = DIR / f"{name}{suffix}"
|
||||
if p.exists():
|
||||
p.unlink()
|
||||
found = True
|
||||
return found
|
||||
|
||||
|
||||
def _alder(sek: int) -> str:
|
||||
if sek < 60:
|
||||
return "lige nu"
|
||||
if sek < 3600:
|
||||
return f"{sek // 60} min siden"
|
||||
if sek < 86400:
|
||||
return f"{sek // 3600} timer siden"
|
||||
return f"{sek // 86400} dage siden"
|
||||
@@ -0,0 +1,45 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Admin — hemmeligheder{% endblock %}
|
||||
{% block body %}
|
||||
<p><a href="/admin" class="wt-muted">← Admin</a></p>
|
||||
<h1>Hemmeligheder til Claude</h1>
|
||||
<p class="wt-lead">Indsæt et token, en nøgle eller et kodeord her i stedet for i chatten. Det krypteres med det samme med serverens offentlige nøgle, og kun den krypterede udgave gemmes. Portalen kan ikke selv læse det igen. Bed derefter Claude om at hente det under navnet du giver det. Det kan kun hentes én gang.</p>
|
||||
{% if saved %}<div class="wt-flash">{{ saved }}</div>{% endif %}
|
||||
{% if gemt %}
|
||||
<section class="wt-card" style="margin:1rem 0;padding:1rem 1.2rem;border:2px solid var(--color-accent, #38bdf8)">
|
||||
<strong>Gemt krypteret som <code>{{ gemt }}</code>.</strong>
|
||||
Skriv fx til Claude: <em>“hent hemmeligheden {{ gemt }}”</em>. Værdien vises ikke igen her.
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/admin/secrets" autocomplete="off" style="margin:1.2rem 0 2rem;max-width:40rem">
|
||||
<label style="display:block;margin:.6rem 0">Navn <span class="wt-muted">(små bogstaver, fx github-token)</span>
|
||||
<input class="wt-input" name="navn" required pattern="[a-z0-9][a-z0-9._\-]{0,63}" maxlength="64" autocomplete="off" spellcheck="false" placeholder="github-token"></label>
|
||||
<label style="display:block;margin:.6rem 0">Værdi
|
||||
<textarea class="wt-input" name="vaerdi" required rows="4" autocomplete="off" spellcheck="false" autocapitalize="off"
|
||||
style="font-family:ui-monospace,monospace;-webkit-text-security:disc" placeholder="indsæt her"></textarea></label>
|
||||
<label style="display:block;margin:.6rem 0">Note <span class="wt-muted">(valgfri, ikke hemmelig: hvad er det til?)</span>
|
||||
<input class="wt-input" name="note" maxlength="200" autocomplete="off" placeholder="fx fine-grained token til homebrew-tap"></label>
|
||||
<button class="wt-btn wt-btn--primary" type="submit">Gem krypteret</button>
|
||||
</form>
|
||||
|
||||
<h2 style="font-size:1.1rem">Venter på at blive hentet</h2>
|
||||
{% if ventende %}
|
||||
<table class="wt-table">
|
||||
<thead><tr><th>Navn</th><th>Note</th><th>Gemt</th><th>Af</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for v in ventende %}
|
||||
<tr>
|
||||
<td><code>{{ v.name }}</code></td>
|
||||
<td>{{ v.note }}</td>
|
||||
<td>{{ v.alder }}</td>
|
||||
<td>{{ v.by }}</td>
|
||||
<td><form method="post" action="/admin/secrets/{{ v.name }}/slet" style="margin:0"><button class="wt-btn" type="submit">Slet</button></form></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="wt-muted">Ingen. Når Claude har hentet en hemmelighed, forsvinder den herfra.</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user