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:
+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"
|
||||
Reference in New Issue
Block a user