Add passphrase keypasses

This commit is contained in:
Ettore
2026-05-10 11:02:03 +02:00
parent 0264425383
commit 54f1ebb62d
5 changed files with 102 additions and 7 deletions

View File

@@ -30,6 +30,9 @@ class KeypassCreate(BaseModel):
expires_at: Optional[datetime] = None # None = never expires expires_at: Optional[datetime] = None # None = never expires
gate_ids: list[int] = [] # empty = all gates gate_ids: list[int] = [] # empty = all gates
code: Optional[str] = None # None = auto-generate code: Optional[str] = None # None = auto-generate
# Auto-generation options (ignored when `code` is supplied manually)
length: int = 12 # 632
charset: str = "alphanumeric" # "alphanumeric" | "alpha" | "numeric" | "passphrase"
class KeypassPatch(BaseModel): class KeypassPatch(BaseModel):

View File

@@ -16,9 +16,66 @@ from core.schemas import KeypassCreate, KeypassPatch, KeypassResponse, keypass_t
router = APIRouter(prefix="/api/admin/keypasses", tags=["admin-keypasses"]) 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: _CHARSETS = {
alphabet = string.ascii_uppercase + string.digits "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)) return "".join(secrets.choice(alphabet) for _ in range(length))
@@ -35,7 +92,7 @@ async def create_keypass(
db: Session = Depends(get_db), db: Session = Depends(get_db),
_: dict = Depends(require_manager), _: 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(): if db.query(Keypass).filter(Keypass.code == code).first():
raise HTTPException(409, "A keypass with this code already exists") raise HTTPException(409, "A keypass with this code already exists")
kp = Keypass( kp = Keypass(
@@ -112,6 +169,6 @@ async def keypass_qr(
img = qr.make_image(fill_color="black", back_color="white") img = qr.make_image(fill_color="black", back_color="white")
buf = io.BytesIO() buf = io.BytesIO()
img.save(buf, format="PNG") img.save(buf)
buf.seek(0) buf.seek(0)
return Response(content=buf.read(), media_type="image/png") return Response(content=buf.read(), media_type="image/png")

View File

@@ -302,7 +302,23 @@
<label for="kp-code">Code <span style="color:var(--text-muted);font-weight:400">(leave empty to auto-generate)</span></label> <label for="kp-code">Code <span style="color:var(--text-muted);font-weight:400">(leave empty to auto-generate)</span></label>
<input id="kp-code" type="text" autocomplete="off" autocorrect="off" autocapitalize="characters" <input id="kp-code" type="text" autocomplete="off" autocorrect="off" autocapitalize="characters"
spellcheck="false" placeholder="Auto-generated" spellcheck="false" placeholder="Auto-generated"
style="font-family:monospace;letter-spacing:.1em;text-transform:uppercase" maxlength="32" /> style="font-family:monospace;letter-spacing:.1em;text-transform:uppercase" maxlength="40" />
</div>
<!-- Auto-generation options — hidden when a manual code is typed -->
<div id="kp-autogen-options" class="field" style="background:var(--surface2);border-radius:8px;padding:.75rem;border:1px solid var(--border);display:flex;flex-wrap:wrap;gap:.75rem;align-items:flex-end">
<div style="display:flex;flex-direction:column;gap:.3rem">
<label for="kp-charset" style="font-size:.8rem;font-weight:600;color:var(--text-muted)">Character set</label>
<select id="kp-charset" style="width:auto;font-size:.9rem">
<option value="alphanumeric">AZ + 09</option>
<option value="alpha">AZ only</option>
<option value="numeric">09 only</option>
<option value="passphrase" selected>Passphrase (4 words)</option>
</select>
</div>
<div id="kp-length-wrap" style="display:flex;flex-direction:column;gap:.3rem">
<label for="kp-length" style="font-size:.8rem;font-weight:600;color:var(--text-muted)">Length <span id="kp-length-val" style="font-weight:700;color:var(--text)">12</span></label>
<input id="kp-length" type="range" min="6" max="32" value="12" style="width:100%;cursor:pointer" />
</div>
</div> </div>
<div class="field" id="kp-expires-field"> <div class="field" id="kp-expires-field">
<label for="kp-expires">Expiry date &amp; time</label> <label for="kp-expires">Expiry date &amp; time</label>

View File

@@ -236,6 +236,12 @@ let _allGates = [];
document.getElementById("btn-new-keypass").addEventListener("click", () => { document.getElementById("btn-new-keypass").addEventListener("click", () => {
document.getElementById("kp-desc").value = ""; document.getElementById("kp-desc").value = "";
document.getElementById("kp-code").value = ""; document.getElementById("kp-code").value = "";
// Reset complexity — default to passphrase
document.getElementById("kp-charset").value = "passphrase";
document.getElementById("kp-length").value = 12;
document.getElementById("kp-length-val").textContent = "12";
document.getElementById("kp-length-wrap").style.display = "none";
document.getElementById("kp-autogen-options").style.display = "";
// Reset never-expires // Reset never-expires
const neverCb = document.getElementById("kp-never-expires"); const neverCb = document.getElementById("kp-never-expires");
neverCb.checked = false; neverCb.checked = false;
@@ -263,6 +269,17 @@ document.getElementById("btn-new-keypass").addEventListener("click", () => {
document.getElementById("keypass-modal").classList.remove("hidden"); document.getElementById("keypass-modal").classList.remove("hidden");
}); });
// Auto-generation options — hide when a manual code is typed, hide length for passphrase
document.getElementById("kp-code").addEventListener("input", e => {
document.getElementById("kp-autogen-options").style.display = e.target.value.trim() ? "none" : "";
});
document.getElementById("kp-charset").addEventListener("change", e => {
document.getElementById("kp-length-wrap").style.display = e.target.value === "passphrase" ? "none" : "";
});
document.getElementById("kp-length").addEventListener("input", e => {
document.getElementById("kp-length-val").textContent = e.target.value;
});
// Never expires toggle // Never expires toggle
document.getElementById("kp-never-expires").addEventListener("change", e => { document.getElementById("kp-never-expires").addEventListener("change", e => {
const kpExpInput = document.getElementById("kp-expires"); const kpExpInput = document.getElementById("kp-expires");
@@ -345,6 +362,8 @@ document.getElementById("keypass-form").addEventListener("submit", async e => {
e.preventDefault(); e.preventDefault();
const desc = document.getElementById("kp-desc").value.trim(); const desc = document.getElementById("kp-desc").value.trim();
const code = document.getElementById("kp-code").value.trim() || null; const code = document.getElementById("kp-code").value.trim() || null;
const charset = document.getElementById("kp-charset").value;
const length = parseInt(document.getElementById("kp-length").value, 10);
const neverExpires = document.getElementById("kp-never-expires").checked; const neverExpires = document.getElementById("kp-never-expires").checked;
const expires_at = neverExpires ? null : new Date(document.getElementById("kp-expires").value).toISOString(); const expires_at = neverExpires ? null : new Date(document.getElementById("kp-expires").value).toISOString();
const allGates = document.getElementById("kp-all-gates").checked; const allGates = document.getElementById("kp-all-gates").checked;
@@ -352,7 +371,7 @@ document.getElementById("keypass-form").addEventListener("submit", async e => {
const errEl = document.getElementById("kp-error"); const errEl = document.getElementById("kp-error");
errEl.classList.add("hidden"); errEl.classList.add("hidden");
try { try {
await api("POST", "/api/admin/keypasses", { description: desc, expires_at, gate_ids, code }); await api("POST", "/api/admin/keypasses", { description: desc, expires_at, gate_ids, code, charset, length });
document.getElementById("keypass-modal").classList.add("hidden"); document.getElementById("keypass-modal").classList.add("hidden");
showToast("Keypass created"); showToast("Keypass created");
loadKeypasses(); loadKeypasses();

View File

@@ -110,7 +110,7 @@
autocapitalize="characters" autocapitalize="characters"
spellcheck="false" spellcheck="false"
placeholder="Enter keypass…" placeholder="Enter keypass…"
maxlength="20" maxlength="40"
/> />
</div> </div>
<p id="login-error" class="error-msg hidden"></p> <p id="login-error" class="error-msg hidden"></p>