Scaffold project, data model and artwork
Establish the LLeMbas foundation: FastAPI/Jinja/SQLite layout, the ORM schema, and the original SVG identity. Notable decisions, all recorded in comments at the point they matter: - No Alembic. SQLite only, schema created at startup, so models carry a few columns nothing reads yet (Message.parent_id for branching, content_parts_json for multimodal turns). Adding them later to a live database without migrations is the painful path. - Sessions are server-side rows keyed by a SHA-256 of the cookie value, not JWTs, so logout and bans revoke access immediately. - Upstream API keys are Fernet-encrypted with a key derived from LEMBAS_SECRET_KEY. decrypt() fails soft to "" so rotating the secret degrades to re-entering keys rather than crashing the admin UI. - Artwork is generated by scripts/build_artwork.py rather than hand-drawn per file: the mallorn leaf appears in the icon, favicon, lockup and banner, and one source is the only way those stay in sync. The wordmark is Source Serif 4 (OFL) converted to outlines, because a README banner cannot load a webfont and <text> would render in whatever serif the viewer happens to have. - Icons live in a template partial, not assets/, because same-document <use href="#id"> is universally supported and the cross-document form is not. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
"""Symmetric encryption for secrets stored in the database.
|
||||
|
||||
Only upstream API keys use this today. The key is derived from
|
||||
``LEMBAS_SECRET_KEY`` rather than stored separately, which means rotating that
|
||||
variable makes every stored API key unreadable -- decrypt() returns "" rather
|
||||
than raising, so the app degrades to "re-enter your keys" instead of crashing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import logging
|
||||
from functools import lru_cache
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
|
||||
from lembas.config import settings
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def _fernet() -> Fernet:
|
||||
# Fernet requires a 32-byte urlsafe-base64 key; SECRET_KEY is free-form text.
|
||||
digest = hashlib.sha256(settings.secret_key.encode("utf-8")).digest()
|
||||
return Fernet(base64.urlsafe_b64encode(digest))
|
||||
|
||||
|
||||
def encrypt(plaintext: str) -> str:
|
||||
"""Encrypt a secret. Empty input stays empty -- keyless endpoints are valid."""
|
||||
if not plaintext:
|
||||
return ""
|
||||
return _fernet().encrypt(plaintext.encode("utf-8")).decode("ascii")
|
||||
|
||||
|
||||
def decrypt(ciphertext: str) -> str:
|
||||
"""Decrypt a secret, returning "" if it cannot be read.
|
||||
|
||||
An unreadable value almost always means LEMBAS_SECRET_KEY changed. Failing
|
||||
soft keeps the admin UI usable so the key can simply be re-entered.
|
||||
"""
|
||||
if not ciphertext:
|
||||
return ""
|
||||
try:
|
||||
return _fernet().decrypt(ciphertext.encode("ascii")).decode("utf-8")
|
||||
except (InvalidToken, ValueError):
|
||||
log.warning("could not decrypt a stored secret; has LEMBAS_SECRET_KEY changed?")
|
||||
return ""
|
||||
|
||||
|
||||
def mask(secret: str) -> str:
|
||||
"""Render a secret for display: never the whole thing, just enough to identify it."""
|
||||
if not secret:
|
||||
return ""
|
||||
if len(secret) <= 8:
|
||||
return "*" * len(secret)
|
||||
return f"{secret[:3]}{'*' * 8}{secret[-4:]}"
|
||||
Reference in New Issue
Block a user