This commit is contained in:
bcjang
2026-03-12 13:31:31 +09:00
commit bc0a546d6f
7 changed files with 1218 additions and 0 deletions

46
README.md Normal file
View File

@@ -0,0 +1,46 @@
# Key Generator
JWT Secret, API Key, Operation Key 등 다양한 보안 키를 생성하는 Python 데스크톱 애플리케이션.
## 실행
```bash
pip install -r requirements.txt
python main.py
```
---
## 지원 키 타입
| 타입 | 비트 | 포맷 |
|------|------|------|
| JWT Secret (HS256) | 256-bit | Hex |
| JWT Secret (HS384) | 384-bit | Hex |
| JWT Secret (HS512) | 512-bit | Hex |
| JWT Secret (Base64URL) | 256-bit | Base64URL |
| API Key `sk-...` | 256-bit | Base64URL |
| Operation Key `ops-...` | 192-bit | Base64URL |
| Random Hex 256-bit | 256-bit | Hex |
| Random Hex 512-bit | 512-bit | Hex |
| Alphanumeric | 256-bit | A-Za-z0-9 |
| UUID v4 | 128-bit | UUID |
| Custom | 자유 | 직접 선택 |
---
## 기능
- **Generate** 버튼 또는 `Ctrl+Enter`로 즉시 생성
- **Copy** 버튼으로 클립보드 복사
- **대량 생성** 체크박스 활성화 시 최대 20개 한번에 생성, **Copy All**로 전체 복사
- **Custom** 타입 선택 시 바이트 수(8~512)와 출력 포맷 직접 지정
- 모든 키는 Python `secrets` 모듈(암호학적 난수) 사용
---
## 의존성
- Python 3.8+
- customtkinter 5.2+
- pyperclip 1.9+

Binary file not shown.

Binary file not shown.

161
app.py Normal file
View File

@@ -0,0 +1,161 @@
import secrets
import hashlib
import hmac
import base64
import uuid
import os
import string
from flask import Flask, render_template, request, jsonify
app = Flask(__name__)
KEY_CONFIGS = {
"jwt_hs256": {
"label": "JWT Secret (HS256)",
"description": "HMAC-SHA256용 JWT 시크릿 키",
"bytes": 32,
"format": "hex",
},
"jwt_hs384": {
"label": "JWT Secret (HS384)",
"description": "HMAC-SHA384용 JWT 시크릿 키",
"bytes": 48,
"format": "hex",
},
"jwt_hs512": {
"label": "JWT Secret (HS512)",
"description": "HMAC-SHA512용 JWT 시크릿 키",
"bytes": 64,
"format": "hex",
},
"jwt_base64": {
"label": "JWT Secret (Base64URL)",
"description": "Base64URL 인코딩 JWT 시크릿 (256-bit)",
"bytes": 32,
"format": "base64url",
},
"api_key": {
"label": "API Key",
"description": "sk- 접두사 포함 API 키",
"bytes": 32,
"format": "api_key",
},
"op_key": {
"label": "Operation Key",
"description": "ops- 접두사 포함 운영 키",
"bytes": 24,
"format": "op_key",
},
"hex_256": {
"label": "Random Hex (256-bit)",
"description": "순수 랜덤 Hex 문자열",
"bytes": 32,
"format": "hex",
},
"hex_512": {
"label": "Random Hex (512-bit)",
"description": "순수 랜덤 Hex 문자열 (512-bit)",
"bytes": 64,
"format": "hex",
},
"alphanumeric": {
"label": "Alphanumeric Key",
"description": "영숫자 조합 랜덤 키",
"bytes": 32,
"format": "alphanumeric",
},
"uuid_v4": {
"label": "UUID v4",
"description": "표준 UUID v4",
"bytes": 16,
"format": "uuid",
},
"custom": {
"label": "Custom Length",
"description": "직접 바이트 수 지정",
"bytes": None,
"format": "hex",
},
}
def generate_key(key_type: str, custom_bytes: int = 32, custom_format: str = "hex") -> dict:
config = KEY_CONFIGS.get(key_type)
if not config:
raise ValueError(f"Unknown key type: {key_type}")
byte_length = config["bytes"] if config["bytes"] is not None else custom_bytes
fmt = custom_format if key_type == "custom" else config["format"]
raw = secrets.token_bytes(byte_length)
if fmt == "hex":
key = raw.hex()
elif fmt == "base64url":
key = base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
elif fmt == "api_key":
encoded = base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
key = f"sk-{encoded}"
elif fmt == "op_key":
encoded = base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
key = f"ops-{encoded}"
elif fmt == "alphanumeric":
alphabet = string.ascii_letters + string.digits
key = "".join(secrets.choice(alphabet) for _ in range(byte_length))
elif fmt == "uuid":
key = str(uuid.uuid4())
else:
key = raw.hex()
return {
"key": key,
"type": key_type,
"label": config["label"],
"bits": byte_length * 8,
"length": len(key),
"algorithm": fmt.upper(),
}
@app.route("/")
def index():
return render_template("index.html", key_configs=KEY_CONFIGS)
@app.route("/api/generate", methods=["POST"])
def api_generate():
data = request.get_json() or {}
key_type = data.get("type", "jwt_hs256")
custom_bytes = int(data.get("custom_bytes", 32))
custom_format = data.get("custom_format", "hex")
if custom_bytes < 8:
custom_bytes = 8
elif custom_bytes > 512:
custom_bytes = 512
try:
result = generate_key(key_type, custom_bytes, custom_format)
return jsonify({"success": True, "data": result})
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 400
@app.route("/api/generate/bulk", methods=["POST"])
def api_generate_bulk():
data = request.get_json() or {}
key_type = data.get("type", "jwt_hs256")
count = min(int(data.get("count", 5)), 20)
custom_bytes = int(data.get("custom_bytes", 32))
custom_format = data.get("custom_format", "hex")
try:
results = [generate_key(key_type, custom_bytes, custom_format) for _ in range(count)]
return jsonify({"success": True, "data": results})
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 400
if __name__ == "__main__":
app.run(debug=True, host="0.0.0.0", port=5050)

378
main.py Normal file
View File

@@ -0,0 +1,378 @@
import secrets
import base64
import uuid
import string
import pyperclip
import customtkinter as ctk
from tkinter import messagebox
ctk.set_appearance_mode("dark")
ctk.set_default_color_theme("blue")
KEY_TYPES = [
("JWT Secret (HS256)", "jwt_hs256", "32 bytes · Hex", 32, "hex"),
("JWT Secret (HS384)", "jwt_hs384", "48 bytes · Hex", 48, "hex"),
("JWT Secret (HS512)", "jwt_hs512", "64 bytes · Hex", 64, "hex"),
("JWT Secret (Base64)", "jwt_base64", "32 bytes · Base64URL", 32, "base64url"),
("API Key (sk-...)", "api_key", "32 bytes · Base64URL", 32, "api_key"),
("Operation Key (ops-)", "op_key", "24 bytes · Base64URL", 24, "op_key"),
("Random Hex 256-bit", "hex_256", "32 bytes · Hex", 32, "hex"),
("Random Hex 512-bit", "hex_512", "64 bytes · Hex", 64, "hex"),
("Alphanumeric", "alphanumeric","32 chars · A-Za-z0-9", 32, "alphanumeric"),
("UUID v4", "uuid_v4", "128-bit · Standard UUID", 16, "uuid"),
("Custom", "custom", "직접 설정", 32, "hex"),
]
FORMATS = ["hex", "base64url", "alphanumeric", "api_key (sk-)", "op_key (ops-)"]
FORMAT_MAP = {
"hex": "hex",
"base64url": "base64url",
"alphanumeric": "alphanumeric",
"api_key (sk-)": "api_key",
"op_key (ops-)": "op_key",
}
def generate_key(byte_length: int, fmt: str) -> str:
raw = secrets.token_bytes(byte_length)
if fmt == "hex":
return raw.hex()
elif fmt == "base64url":
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
elif fmt == "api_key":
return "sk-" + base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
elif fmt == "op_key":
return "ops-" + base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
elif fmt == "alphanumeric":
alpha = string.ascii_letters + string.digits
return "".join(secrets.choice(alpha) for _ in range(byte_length))
elif fmt == "uuid":
return str(uuid.uuid4())
return raw.hex()
class App(ctk.CTk):
def __init__(self):
super().__init__()
self.title("Key Generator")
self.geometry("760x700")
self.resizable(False, False)
self._build_ui()
def _build_ui(self):
# ── Title ──────────────────────────────────────────────
title = ctk.CTkLabel(
self, text=" Key Generator",
font=ctk.CTkFont(size=22, weight="bold"),
anchor="w",
)
title.pack(padx=28, pady=(24, 2), anchor="w")
sub = ctk.CTkLabel(
self,
text="JWT Secret · API Key · Operation Key · UUID · Random Hex",
font=ctk.CTkFont(size=12),
text_color="#8892a4",
anchor="w",
)
sub.pack(padx=30, pady=(0, 16), anchor="w")
# ── Key Type ───────────────────────────────────────────
self._section("키 타입")
self._type_var = ctk.StringVar(value="jwt_hs256")
type_frame = ctk.CTkFrame(self, fg_color="transparent")
type_frame.pack(padx=28, pady=(4, 0), fill="x")
self._type_combo = ctk.CTkComboBox(
type_frame,
values=[t[0] for t in KEY_TYPES],
width=340,
height=36,
font=ctk.CTkFont(size=13),
command=self._on_type_change,
)
self._type_combo.set(KEY_TYPES[0][0])
self._type_combo.pack(side="left")
self._desc_label = ctk.CTkLabel(
type_frame,
text=KEY_TYPES[0][2],
font=ctk.CTkFont(size=11),
text_color="#8892a4",
)
self._desc_label.pack(side="left", padx=(14, 0))
# ── Custom Options ─────────────────────────────────────
self._custom_frame = ctk.CTkFrame(self, fg_color=("#1a1d27", "#1a1d27"), corner_radius=10)
self._custom_frame.pack(padx=28, pady=(12, 0), fill="x")
ctk.CTkLabel(
self._custom_frame, text="바이트 수",
font=ctk.CTkFont(size=11), text_color="#8892a4",
).grid(row=0, column=0, padx=(16, 0), pady=(12, 2), sticky="w")
ctk.CTkLabel(
self._custom_frame, text="출력 포맷",
font=ctk.CTkFont(size=11), text_color="#8892a4",
).grid(row=0, column=1, padx=(20, 0), pady=(12, 2), sticky="w")
self._bytes_entry = ctk.CTkEntry(
self._custom_frame, width=100, height=34,
font=ctk.CTkFont(size=13), placeholder_text="32",
)
self._bytes_entry.insert(0, "32")
self._bytes_entry.grid(row=1, column=0, padx=(16, 0), pady=(0, 14), sticky="w")
self._fmt_combo = ctk.CTkComboBox(
self._custom_frame, values=FORMATS,
width=200, height=34, font=ctk.CTkFont(size=13),
)
self._fmt_combo.set("hex")
self._fmt_combo.grid(row=1, column=1, padx=(20, 0), pady=(0, 14), sticky="w")
self._custom_frame.pack_forget() # hidden by default
# ── Bulk ───────────────────────────────────────────────
self._section("옵션")
bulk_frame = ctk.CTkFrame(self, fg_color="transparent")
bulk_frame.pack(padx=28, pady=(4, 0), fill="x")
self._bulk_var = ctk.BooleanVar(value=False)
bulk_chk = ctk.CTkCheckBox(
bulk_frame, text="대량 생성",
font=ctk.CTkFont(size=13),
variable=self._bulk_var,
command=self._on_bulk_toggle,
)
bulk_chk.pack(side="left")
self._bulk_label = ctk.CTkLabel(
bulk_frame, text="개수",
font=ctk.CTkFont(size=11), text_color="#8892a4",
)
self._bulk_count = ctk.CTkEntry(
bulk_frame, width=64, height=30,
font=ctk.CTkFont(size=13), placeholder_text="5",
)
self._bulk_count.insert(0, "5")
# hidden initially
# ── Generate Button ────────────────────────────────────
self._gen_btn = ctk.CTkButton(
self,
text="Generate",
height=44,
font=ctk.CTkFont(size=14, weight="bold"),
corner_radius=10,
command=self._on_generate,
)
self._gen_btn.pack(padx=28, pady=(20, 0), fill="x")
# ── Result ─────────────────────────────────────────────
self._section("생성된 키")
# Single result
self._result_frame = ctk.CTkFrame(self, fg_color="transparent")
self._result_frame.pack(padx=28, pady=(4, 0), fill="x")
self._key_box = ctk.CTkTextbox(
self._result_frame,
height=72,
font=ctk.CTkFont(family="Consolas", size=12),
fg_color=("#1a1d27", "#1a1d27"),
text_color="#a5f3fc",
corner_radius=10,
wrap="word",
state="disabled",
)
self._key_box.pack(fill="x")
# Meta row
self._meta_label = ctk.CTkLabel(
self._result_frame, text="",
font=ctk.CTkFont(size=11), text_color="#8892a4", anchor="w",
)
self._meta_label.pack(pady=(6, 0), anchor="w")
# Copy button
copy_row = ctk.CTkFrame(self._result_frame, fg_color="transparent")
copy_row.pack(fill="x", pady=(8, 0))
self._copy_btn = ctk.CTkButton(
copy_row,
text="Copy",
width=100, height=34,
font=ctk.CTkFont(size=12),
fg_color=("#22263a", "#22263a"),
hover_color=("#2e3250", "#2e3250"),
border_width=1,
border_color="#2e3250",
corner_radius=8,
command=self._copy_single,
)
self._copy_btn.pack(side="right")
# Bulk result
self._bulk_frame_result = ctk.CTkScrollableFrame(
self, height=200,
fg_color=("#1a1d27", "#1a1d27"),
corner_radius=10,
)
self._bulk_frame_result.pack(padx=28, pady=(4, 0), fill="x")
self._bulk_frame_result.pack_forget()
self._bulk_copy_all_btn = ctk.CTkButton(
self,
text="Copy All",
height=34,
font=ctk.CTkFont(size=12),
fg_color=("#22263a", "#22263a"),
hover_color=("#2e3250", "#2e3250"),
border_width=1,
border_color="#2e3250",
corner_radius=8,
command=self._copy_all,
)
self._bulk_keys = []
# Keyboard shortcut
self.bind("<Control-Return>", lambda e: self._on_generate())
def _section(self, text: str):
lbl = ctk.CTkLabel(
self, text=text.upper(),
font=ctk.CTkFont(size=10, weight="bold"),
text_color="#8892a4", anchor="w",
)
lbl.pack(padx=30, pady=(16, 0), anchor="w")
def _on_type_change(self, value: str):
entry = next((t for t in KEY_TYPES if t[0] == value), None)
if not entry:
return
self._desc_label.configure(text=entry[2])
if entry[1] == "custom":
self._custom_frame.pack(padx=28, pady=(12, 0), fill="x")
else:
self._custom_frame.pack_forget()
def _on_bulk_toggle(self):
if self._bulk_var.get():
self._bulk_label.pack(side="left", padx=(16, 4))
self._bulk_count.pack(side="left")
else:
self._bulk_label.pack_forget()
self._bulk_count.pack_forget()
def _get_selected_type(self):
label = self._type_combo.get()
return next((t for t in KEY_TYPES if t[0] == label), KEY_TYPES[0])
def _on_generate(self):
entry = self._get_selected_type()
_, key_id, _, byte_len, fmt = entry
if key_id == "custom":
try:
byte_len = int(self._bytes_entry.get())
byte_len = max(8, min(512, byte_len))
except ValueError:
byte_len = 32
fmt = FORMAT_MAP.get(self._fmt_combo.get(), "hex")
if self._bulk_var.get():
try:
count = int(self._bulk_count.get())
count = max(1, min(20, count))
except ValueError:
count = 5
self._generate_bulk(byte_len, fmt, count, entry)
else:
self._generate_single(byte_len, fmt, entry)
def _generate_single(self, byte_len, fmt, entry):
key = generate_key(byte_len, fmt)
bits = byte_len * 8
self._key_box.configure(state="normal")
self._key_box.delete("1.0", "end")
self._key_box.insert("1.0", key)
self._key_box.configure(state="disabled")
self._meta_label.configure(
text=f"{entry[0]} · {bits}-bit · {fmt.upper()} · {len(key)} chars"
)
# Reset copy button
self._copy_btn.configure(text="Copy", fg_color=("#22263a", "#22263a"))
# Show single, hide bulk
self._result_frame.pack(padx=28, pady=(4, 0), fill="x")
self._bulk_frame_result.pack_forget()
self._bulk_copy_all_btn.pack_forget()
def _generate_bulk(self, byte_len, fmt, count, entry):
self._bulk_keys = [generate_key(byte_len, fmt) for _ in range(count)]
# Clear old rows
for w in self._bulk_frame_result.winfo_children():
w.destroy()
for i, key in enumerate(self._bulk_keys):
row = ctk.CTkFrame(self._bulk_frame_result, fg_color="transparent")
row.pack(fill="x", pady=3)
ctk.CTkLabel(
row, text=f"#{i+1:02d}",
font=ctk.CTkFont(family="Consolas", size=11),
text_color="#4a5568", width=32, anchor="w",
).pack(side="left", padx=(4, 0))
ctk.CTkLabel(
row, text=key,
font=ctk.CTkFont(family="Consolas", size=11),
text_color="#a5f3fc", anchor="w",
wraplength=500,
).pack(side="left", padx=(6, 0), fill="x", expand=True)
btn = ctk.CTkButton(
row, text="Copy", width=60, height=26,
font=ctk.CTkFont(size=11),
fg_color=("#22263a", "#22263a"),
hover_color=("#2e3250", "#2e3250"),
border_width=1, border_color="#2e3250",
corner_radius=6,
command=lambda k=key, b=None: self._copy_item(k),
)
btn.pack(side="right", padx=(0, 4))
# Show bulk, hide single
self._result_frame.pack_forget()
self._bulk_frame_result.pack(padx=28, pady=(4, 0), fill="x")
self._bulk_copy_all_btn.pack(padx=28, pady=(8, 0), anchor="e")
def _copy_single(self):
key = self._key_box.get("1.0", "end").strip()
if not key:
return
pyperclip.copy(key)
self._copy_btn.configure(text="Copied!", fg_color=("#1a3a2a", "#1a3a2a"))
self.after(2000, lambda: self._copy_btn.configure(
text="Copy", fg_color=("#22263a", "#22263a")
))
def _copy_item(self, key: str):
pyperclip.copy(key)
def _copy_all(self):
if self._bulk_keys:
pyperclip.copy("\n".join(self._bulk_keys))
self._bulk_copy_all_btn.configure(text="Copied!")
self.after(2000, lambda: self._bulk_copy_all_btn.configure(text="Copy All"))
if __name__ == "__main__":
app = App()
app.mainloop()

2
requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
customtkinter>=5.2.0
pyperclip>=1.9.0

631
templates/index.html Normal file
View File

@@ -0,0 +1,631 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Key Generator</title>
<style>
:root {
--bg: #0f1117;
--surface: #1a1d27;
--surface2: #22263a;
--border: #2e3250;
--accent: #6c63ff;
--accent-hover: #7c74ff;
--accent-dim: rgba(108, 99, 255, 0.15);
--success: #22c55e;
--success-dim: rgba(34, 197, 94, 0.12);
--text: #e2e8f0;
--text-muted: #8892a4;
--text-dim: #4a5568;
--mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 40px 16px 80px;
}
/* Header */
header {
text-align: center;
margin-bottom: 40px;
}
header h1 {
font-size: 2rem;
font-weight: 700;
letter-spacing: -0.02em;
background: linear-gradient(135deg, #6c63ff, #a78bfa);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
header p {
margin-top: 8px;
color: var(--text-muted);
font-size: 0.9rem;
}
/* Main card */
.card {
width: 100%;
max-width: 720px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 16px;
padding: 32px;
}
/* Section label */
.section-label {
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 10px;
}
/* Key type grid */
.type-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 8px;
margin-bottom: 24px;
}
.type-btn {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 10px;
padding: 12px 14px;
cursor: pointer;
text-align: left;
transition: all 0.15s;
color: var(--text);
}
.type-btn:hover {
border-color: var(--accent);
background: var(--accent-dim);
}
.type-btn.active {
border-color: var(--accent);
background: var(--accent-dim);
box-shadow: 0 0 0 1px var(--accent);
}
.type-btn .btn-label {
font-size: 0.85rem;
font-weight: 600;
}
.type-btn .btn-desc {
font-size: 0.72rem;
color: var(--text-muted);
margin-top: 3px;
line-height: 1.4;
}
/* Custom options */
.custom-options {
display: none;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 10px;
padding: 16px;
margin-bottom: 24px;
gap: 16px;
flex-wrap: wrap;
align-items: flex-end;
}
.custom-options.visible {
display: flex;
}
.field-group {
display: flex;
flex-direction: column;
gap: 6px;
flex: 1;
min-width: 120px;
}
.field-group label {
font-size: 0.75rem;
color: var(--text-muted);
font-weight: 500;
}
.field-group input,
.field-group select {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
padding: 8px 12px;
font-size: 0.85rem;
outline: none;
transition: border-color 0.15s;
}
.field-group input:focus,
.field-group select:focus {
border-color: var(--accent);
}
.field-group select option {
background: var(--surface);
}
/* Divider */
.divider {
border: none;
border-top: 1px solid var(--border);
margin: 8px 0 24px;
}
/* Bulk toggle */
.bulk-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 20px;
}
.bulk-row label {
font-size: 0.82rem;
color: var(--text-muted);
}
.bulk-row input[type=number] {
width: 64px;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
padding: 6px 10px;
font-size: 0.82rem;
outline: none;
text-align: center;
}
.toggle-switch {
position: relative;
width: 38px;
height: 22px;
cursor: pointer;
}
.toggle-switch input { display: none; }
.toggle-track {
position: absolute;
inset: 0;
background: var(--border);
border-radius: 999px;
transition: background 0.2s;
}
.toggle-switch input:checked ~ .toggle-track { background: var(--accent); }
.toggle-thumb {
position: absolute;
width: 16px;
height: 16px;
background: white;
border-radius: 50%;
top: 3px;
left: 3px;
transition: transform 0.2s;
}
.toggle-switch input:checked ~ .toggle-track .toggle-thumb {
transform: translateX(16px);
}
/* Generate button */
.generate-btn {
width: 100%;
padding: 14px;
background: var(--accent);
color: white;
border: none;
border-radius: 10px;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
letter-spacing: 0.02em;
transition: background 0.15s, transform 0.1s;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.generate-btn:hover { background: var(--accent-hover); }
.generate-btn:active { transform: scale(0.99); }
.generate-btn .spinner {
width: 16px;
height: 16px;
border: 2px solid rgba(255,255,255,0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.6s linear infinite;
display: none;
}
.generate-btn.loading .spinner { display: block; }
.generate-btn.loading .btn-text { opacity: 0.7; }
@keyframes spin { to { transform: rotate(360deg); } }
/* Result area */
.result-area {
margin-top: 24px;
}
.result-single {
position: relative;
}
.key-display {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 10px;
padding: 16px 16px 14px;
font-family: var(--mono);
font-size: 0.82rem;
line-height: 1.6;
word-break: break-all;
color: #a5f3fc;
min-height: 56px;
transition: border-color 0.2s;
}
.key-meta {
display: flex;
gap: 10px;
margin-top: 8px;
flex-wrap: wrap;
}
.meta-tag {
font-size: 0.7rem;
padding: 2px 8px;
border-radius: 999px;
background: var(--surface2);
border: 1px solid var(--border);
color: var(--text-muted);
}
.meta-tag.highlight {
background: var(--accent-dim);
border-color: var(--accent);
color: var(--accent);
}
/* Copy button */
.copy-row {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
.copy-btn {
display: flex;
align-items: center;
gap: 6px;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text-muted);
padding: 7px 14px;
font-size: 0.8rem;
cursor: pointer;
transition: all 0.15s;
font-weight: 500;
}
.copy-btn:hover {
border-color: var(--accent);
color: var(--accent);
background: var(--accent-dim);
}
.copy-btn.copied {
border-color: var(--success);
color: var(--success);
background: var(--success-dim);
}
/* Bulk results */
.bulk-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.bulk-item {
display: flex;
align-items: center;
gap: 10px;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 10px;
padding: 10px 14px;
}
.bulk-index {
font-size: 0.7rem;
color: var(--text-dim);
font-family: var(--mono);
min-width: 20px;
}
.bulk-key {
flex: 1;
font-family: var(--mono);
font-size: 0.78rem;
color: #a5f3fc;
word-break: break-all;
line-height: 1.5;
}
.bulk-copy {
flex-shrink: 0;
background: transparent;
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-muted);
padding: 5px 10px;
font-size: 0.72rem;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.bulk-copy:hover {
border-color: var(--accent);
color: var(--accent);
}
.bulk-copy.copied {
border-color: var(--success);
color: var(--success);
}
.bulk-copy-all {
display: flex;
justify-content: flex-end;
margin-top: 10px;
}
/* Icon SVG */
svg { display: inline-block; vertical-align: middle; }
</style>
</head>
<body>
<header>
<h1>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="url(#g)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:8px">
<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0%" stop-color="#6c63ff"/><stop offset="100%" stop-color="#a78bfa"/></linearGradient></defs>
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/>
</svg>
Key Generator
</h1>
<p>JWT Secret · API Key · Operation Key · UUID · Random Hex</p>
</header>
<div class="card">
<!-- Key Type Selection -->
<div class="section-label">키 타입 선택</div>
<div class="type-grid" id="typeGrid">
{% for key, cfg in key_configs.items() %}
<button class="type-btn {% if key == 'jwt_hs256' %}active{% endif %}"
data-type="{{ key }}"
onclick="selectType('{{ key }}', this)">
<div class="btn-label">{{ cfg.label }}</div>
<div class="btn-desc">{{ cfg.description }}</div>
</button>
{% endfor %}
</div>
<!-- Custom Options (visible when custom is selected) -->
<div class="custom-options" id="customOptions">
<div class="field-group">
<label>바이트 수 (8 ~ 512)</label>
<input type="number" id="customBytes" value="32" min="8" max="512" />
</div>
<div class="field-group">
<label>출력 포맷</label>
<select id="customFormat">
<option value="hex">Hex</option>
<option value="base64url">Base64URL</option>
<option value="alphanumeric">Alphanumeric</option>
<option value="api_key">API Key (sk- prefix)</option>
<option value="op_key">Op Key (ops- prefix)</option>
</select>
</div>
</div>
<hr class="divider" />
<!-- Bulk toggle -->
<div class="bulk-row">
<label class="toggle-switch">
<input type="checkbox" id="bulkToggle" onchange="toggleBulk()" />
<span class="toggle-track"><span class="toggle-thumb"></span></span>
</label>
<label for="bulkToggle" style="cursor:pointer">대량 생성</label>
<input type="number" id="bulkCount" value="5" min="1" max="20"
style="display:none" />
<label id="bulkCountLabel" style="display:none"></label>
</div>
<!-- Generate Button -->
<button class="generate-btn" id="generateBtn" onclick="generate()">
<div class="spinner"></div>
<span class="btn-text">Generate</span>
</button>
<!-- Result -->
<div class="result-area" id="resultArea" style="display:none">
<div class="section-label" style="margin-bottom:12px">생성된 키</div>
<!-- Single result -->
<div class="result-single" id="resultSingle">
<div class="key-display" id="keyDisplay"></div>
<div class="key-meta" id="keyMeta"></div>
<div class="copy-row">
<button class="copy-btn" id="copyBtn" onclick="copyKey()">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
Copy
</button>
</div>
</div>
<!-- Bulk result -->
<div id="resultBulk" style="display:none">
<div class="bulk-list" id="bulkList"></div>
<div class="bulk-copy-all">
<button class="copy-btn" onclick="copyAll()">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
Copy All
</button>
</div>
</div>
</div>
</div>
<script>
let selectedType = 'jwt_hs256';
let bulkMode = false;
let lastBulkKeys = [];
function selectType(type, el) {
selectedType = type;
document.querySelectorAll('.type-btn').forEach(b => b.classList.remove('active'));
el.classList.add('active');
const customOpts = document.getElementById('customOptions');
if (type === 'custom') {
customOpts.classList.add('visible');
} else {
customOpts.classList.remove('visible');
}
}
function toggleBulk() {
bulkMode = document.getElementById('bulkToggle').checked;
const countInput = document.getElementById('bulkCount');
const countLabel = document.getElementById('bulkCountLabel');
countInput.style.display = bulkMode ? 'block' : 'none';
countLabel.style.display = bulkMode ? 'block' : 'none';
}
async function generate() {
const btn = document.getElementById('generateBtn');
btn.classList.add('loading');
const payload = {
type: selectedType,
custom_bytes: parseInt(document.getElementById('customBytes').value) || 32,
custom_format: document.getElementById('customFormat').value,
};
try {
if (bulkMode) {
const count = parseInt(document.getElementById('bulkCount').value) || 5;
const res = await fetch('/api/generate/bulk', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...payload, count }),
});
const json = await res.json();
if (json.success) renderBulk(json.data);
} else {
const res = await fetch('/api/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const json = await res.json();
if (json.success) renderSingle(json.data);
}
} catch (e) {
console.error(e);
} finally {
btn.classList.remove('loading');
}
}
function renderSingle(data) {
document.getElementById('resultArea').style.display = 'block';
document.getElementById('resultSingle').style.display = 'block';
document.getElementById('resultBulk').style.display = 'none';
document.getElementById('keyDisplay').textContent = data.key;
const meta = document.getElementById('keyMeta');
meta.innerHTML = `
<span class="meta-tag highlight">${data.label}</span>
<span class="meta-tag">${data.bits} bit</span>
<span class="meta-tag">${data.algorithm}</span>
<span class="meta-tag">${data.length} chars</span>
`;
const copyBtn = document.getElementById('copyBtn');
copyBtn.classList.remove('copied');
copyBtn.innerHTML = `
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg> Copy`;
}
function renderBulk(items) {
lastBulkKeys = items.map(i => i.key);
document.getElementById('resultArea').style.display = 'block';
document.getElementById('resultSingle').style.display = 'none';
document.getElementById('resultBulk').style.display = 'block';
const list = document.getElementById('bulkList');
list.innerHTML = items.map((item, idx) => `
<div class="bulk-item">
<span class="bulk-index">#${idx + 1}</span>
<span class="bulk-key">${item.key}</span>
<button class="bulk-copy" onclick="copyBulkItem(this, '${escHtml(item.key)}')">Copy</button>
</div>
`).join('');
}
function escHtml(s) {
return s.replace(/'/g, "\\'");
}
function copyKey() {
const key = document.getElementById('keyDisplay').textContent;
navigator.clipboard.writeText(key).then(() => {
const btn = document.getElementById('copyBtn');
btn.classList.add('copied');
btn.innerHTML = `
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg> Copied!`;
setTimeout(() => {
btn.classList.remove('copied');
btn.innerHTML = `
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg> Copy`;
}, 2000);
});
}
function copyBulkItem(btn, key) {
navigator.clipboard.writeText(key).then(() => {
btn.classList.add('copied');
btn.textContent = 'Copied!';
setTimeout(() => {
btn.classList.remove('copied');
btn.textContent = 'Copy';
}, 2000);
});
}
function copyAll() {
const text = lastBulkKeys.join('\n');
navigator.clipboard.writeText(text).then(() => {
alert('전체 키가 클립보드에 복사되었습니다!');
});
}
// Enter key shortcut
document.addEventListener('keydown', e => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) generate();
});
</script>
</body>
</html>