From 54f1ebb62db407e162b21c2d82952a93a051a6b8 Mon Sep 17 00:00:00 2001 From: Ettore <=> Date: Sun, 10 May 2026 11:02:03 +0200 Subject: [PATCH] Add passphrase keypasses --- src/core/schemas.py | 3 ++ src/routers/keypasses.py | 65 +++++++++++++++++++++++++++++++++++++--- src/static/admin.html | 18 ++++++++++- src/static/admin.js | 21 ++++++++++++- src/static/index.html | 2 +- 5 files changed, 102 insertions(+), 7 deletions(-) diff --git a/src/core/schemas.py b/src/core/schemas.py index d2b5554..754448d 100644 --- a/src/core/schemas.py +++ b/src/core/schemas.py @@ -30,6 +30,9 @@ class KeypassCreate(BaseModel): expires_at: Optional[datetime] = None # None = never expires gate_ids: list[int] = [] # empty = all gates code: Optional[str] = None # None = auto-generate + # Auto-generation options (ignored when `code` is supplied manually) + length: int = 12 # 6–32 + charset: str = "alphanumeric" # "alphanumeric" | "alpha" | "numeric" | "passphrase" class KeypassPatch(BaseModel): diff --git a/src/routers/keypasses.py b/src/routers/keypasses.py index c7217bd..9d2b41c 100644 --- a/src/routers/keypasses.py +++ b/src/routers/keypasses.py @@ -16,9 +16,66 @@ from core.schemas import KeypassCreate, KeypassPatch, KeypassResponse, keypass_t router = APIRouter(prefix="/api/admin/keypasses", tags=["admin-keypasses"]) +# ── Word list for passphrase mode ───────────────────────────────────────────── +_WORDS = [ + "apple", "beach", "brick", "brush", "cabin", "calm", "cedar", "chain", + "chalk", "chase", "chest", "clear", "cliff", "clock", "cloud", "coast", + "coral", "crane", "creek", "crisp", "crown", "curve", "dance", "delta", + "depot", "drift", "drive", "drops", "dunes", "eagle", "earth", "ember", + "fence", "field", "final", "flame", "flash", "fleet", "flint", "floor", + "focus", "forge", "forth", "frost", "fruit", "glade", "gleam", "globe", + "gloom", "gloss", "grace", "grain", "grand", "grape", "grass", "gravel", + "green", "grove", "guard", "guide", "haven", "heart", "honey", "honor", + "hatch", "image", "inlet", "ivory", "joker", "karma", "knoll", "lake", + "lance", "large", "laser", "latch", "layer", "ledge", "light", "linen", + "links", "liver", "lunar", "mango", "maple", "march", "marsh", "merit", + "metal", "minor", "mirth", "misty", "mixer", "mocha", "model", "money", + "mount", "mouse", "named", "nerve", "night", "noble", "north", "notch", + "novel", "oaken", "ocean", "olive", "ombre", "ozone", "panel", "paper", + "patch", "pause", "peace", "pearl", "petal", "pilot", "pinch", "pixel", + "plain", "plane", "plaza", "plumb", "plume", "plush", "polar", "porch", + "power", "prism", "prize", "probe", "proxy", "pulse", "quail", "quartz", + "queen", "quest", "queue", "quiet", "quota", "quote", "radar", "radix", + "rally", "ranch", "rapid", "raven", "reach", "realm", "relay", "repay", + "resin", "ridge", "rivet", "river", "rogue", "round", "route", "rover", + "royal", "rusty", "saint", "salsa", "salvo", "sandy", "score", "scout", + "serum", "shade", "shaft", "shale", "shall", "shape", "sharp", "sheen", + "shelf", "shell", "shift", "shiny", "shore", "short", "sigma", "silky", + "silva", "since", "sixth", "slate", "slope", "smoke", "solar", "solid", + "sonic", "sound", "south", "space", "spark", "spear", "spend", "spire", + "split", "spoke", "spore", "sport", "spray", "spree", "sprig", "squad", + "stack", "staff", "stage", "stain", "stake", "stale", "stall", "stamp", + "stand", "stark", "start", "state", "stays", "steel", "steep", "steer", + "stern", "stock", "stone", "storm", "story", "stove", "strap", "straw", + "strip", "strut", "study", "stuff", "sugar", "suite", "sunny", "surge", + "swamp", "swarm", "swept", "swing", "sword", "synth", "table", "talon", + "tango", "taste", "tawny", "tempo", "thatch", "theme", "think", "thorn", + "three", "threw", "tiger", "tidal", "titan", "token", "topaz", "torch", + "total", "touch", "tough", "towel", "tower", "trace", "track", "trade", + "trail", "train", "trait", "tramp", "trawl", "trend", "trial", "tribe", + "trick", "tried", "troop", "trove", "truce", "trunk", "trust", "tuber", + "tundra", "tuner", "twill", "twist", "ultra", "umbra", "uncle", "union", + "unity", "until", "upper", "urged", "usage", "utmost", "valor", "vault", + "vibes", "vigor", "viper", "vista", "vital", "vivid", "voice", "voter", + "vroom", "waltz", "water", "waves", "wedge", "weird", "wheat", "wheel", + "where", "whirl", "white", "whole", "widow", "width", "winds", "windy", + "witch", "world", "wreck", "wrist", "xerox", "yacht", "yards", "years", + "yield", "young", "youth", "zeal", "zebra", "zones", "zenith", +] -def _generate_code(length: int = 12) -> str: - alphabet = string.ascii_uppercase + string.digits +_CHARSETS = { + "alphanumeric": string.ascii_uppercase + string.digits, + "alpha": string.ascii_uppercase, + "numeric": string.digits, +} + + +def _generate_code(length: int = 12, charset: str = "alphanumeric") -> str: + if charset == "passphrase": + # 4 random words joined by hyphens; `length` is ignored for passphrases + return "-".join(secrets.choice(_WORDS).upper() for _ in range(4)) + alphabet = _CHARSETS.get(charset, _CHARSETS["alphanumeric"]) + length = max(6, min(length, 32)) return "".join(secrets.choice(alphabet) for _ in range(length)) @@ -35,7 +92,7 @@ async def create_keypass( db: Session = Depends(get_db), _: dict = Depends(require_manager), ): - code = req.code.strip().upper() if req.code and req.code.strip() else _generate_code() + code = req.code.strip().upper() if req.code and req.code.strip() else _generate_code(req.length, req.charset) if db.query(Keypass).filter(Keypass.code == code).first(): raise HTTPException(409, "A keypass with this code already exists") kp = Keypass( @@ -112,6 +169,6 @@ async def keypass_qr( img = qr.make_image(fill_color="black", back_color="white") buf = io.BytesIO() - img.save(buf, format="PNG") + img.save(buf) buf.seek(0) return Response(content=buf.read(), media_type="image/png") diff --git a/src/static/admin.html b/src/static/admin.html index faeaaeb..f23ac73 100644 --- a/src/static/admin.html +++ b/src/static/admin.html @@ -302,7 +302,23 @@ + style="font-family:monospace;letter-spacing:.1em;text-transform:uppercase" maxlength="40" /> + + +