This commit is contained in:
bcjang
2026-03-12 14:17:39 +09:00
parent bc0a546d6f
commit 413cdf8cc2
10 changed files with 385 additions and 54 deletions

View File

@@ -0,0 +1,11 @@
{
"permissions": {
"allow": [
"mcp__serena__activate_project",
"mcp__serena__list_dir",
"mcp__serena__get_symbols_overview",
"mcp__serena__find_symbol",
"Bash(python -c \"\nfrom app import generate_key\nr = generate_key\\('jwt_rs256'\\)\nprint\\('RS256 keypair:', r['keypair'], r['algorithm'], r['bits'], 'bit'\\)\nprint\\('private key starts with:', r['key'][:27]\\)\nprint\\('public key starts with:', r['public_key'][:26]\\)\n\ne = generate_key\\('jwt_es256'\\)\nprint\\('ES256 keypair:', e['keypair'], e['algorithm'], e['bits'], 'bit'\\)\nprint\\('private key starts with:', e['key'][:27]\\)\n\nh = generate_key\\('jwt_hs256'\\)\nprint\\('HS256:', h['keypair'], h['algorithm'], h['bits'], 'bit'\\)\n\")"
]
}
}

2
.serena/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/cache
/project.local.yml

135
.serena/project.yml Normal file
View File

@@ -0,0 +1,135 @@
# the name by which the project can be referenced within Serena
project_name: "key-generator"
# list of languages for which language servers are started; choose from:
# al bash clojure cpp csharp
# csharp_omnisharp dart elixir elm erlang
# fortran fsharp go groovy haskell
# java julia kotlin lua markdown
# matlab nix pascal perl php
# php_phpactor powershell python python_jedi r
# rego ruby ruby_solargraph rust scala
# swift terraform toml typescript typescript_vts
# vue yaml zig
# (This list may be outdated. For the current list, see values of Language enum here:
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
# Note:
# - For C, use cpp
# - For JavaScript, use typescript
# - For Free Pascal/Lazarus, use pascal
# Special requirements:
# Some languages require additional setup/installations.
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
# When using multiple languages, the first language server that supports a given file will be used for that file.
# The first language is the default language and the respective language server will be used as a fallback.
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
languages:
- python
# the encoding used by text files in the project
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
encoding: "utf-8"
# line ending convention to use when writing source files.
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
line_ending:
# The language backend to use for this project.
# If not set, the global setting from serena_config.yml is used.
# Valid values: LSP, JetBrains
# Note: the backend is fixed at startup. If a project with a different backend
# is activated post-init, an error will be returned.
language_backend:
# whether to use project's .gitignore files to ignore files
ignore_all_files_in_gitignore: true
# list of additional paths to ignore in this project.
# Same syntax as gitignore, so you can use * and **.
# Note: global ignored_paths from serena_config.yml are also applied additively.
ignored_paths: []
# whether the project is in read-only mode
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
# Added on 2025-04-18
read_only: false
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
# Below is the complete list of tools for convenience.
# To make sure you have the latest list of tools, and to view their descriptions,
# execute `uv run scripts/print_tool_overview.py`.
#
# * `activate_project`: Activates a project by name.
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
# * `create_text_file`: Creates/overwrites a file in the project directory.
# * `delete_lines`: Deletes a range of lines within a file.
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
# * `execute_shell_command`: Executes a shell command.
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
# * `initial_instructions`: Gets the initial instructions for the current project.
# Should only be used in settings where the system prompt cannot be set,
# e.g. in clients you have no control over, like Claude Desktop.
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
# * `insert_at_line`: Inserts content at a given line in a file.
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
# * `list_memories`: Lists memories in Serena's project-specific memory store.
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
# * `read_file`: Reads a file within the project directory.
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
# * `remove_project`: Removes a project from the Serena configuration.
# * `replace_lines`: Replaces a range of lines within a file with new content.
# * `replace_symbol_body`: Replaces the full definition of a symbol.
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
# * `search_for_pattern`: Performs a search for a pattern in the project.
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
# * `switch_modes`: Activates modes by providing a list of their names
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
excluded_tools: []
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default)
included_optional_tools: []
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
fixed_tools: []
# list of mode names to that are always to be included in the set of active modes
# The full set of modes to be activated is base_modes + default_modes.
# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this setting overrides the global configuration.
# Set this to [] to disable base modes for this project.
# Set this to a list of mode names to always include the respective modes for this project.
base_modes:
# list of mode names that are to be activated by default.
# The full set of modes to be activated is base_modes + default_modes.
# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
# This setting can, in turn, be overridden by CLI parameters (--mode).
default_modes:
# initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand).
initial_prompt: ""
# time budget (seconds) per tool call for the retrieval of additional symbol information
# such as docstrings or parameter information.
# This overrides the corresponding setting in the global configuration; see the documentation there.
# If null or missing, use the setting from the global configuration.
symbol_info_budget:
# list of regex patterns which, when matched, mark a memory entry as readonly.
# Extends the list from the global configuration, merging the two lists.
read_only_memory_patterns: []

3
=41.0.0 Normal file
View File

@@ -0,0 +1,3 @@
[notice] A new release of pip is available: 25.0.1 -> 26.0.1
[notice] To update, run: python.exe -m pip install --upgrade pip

View File

@@ -13,19 +13,21 @@ 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 | 자유 | 직접 선택 |
| 타입 | 비트 | 포맷 | 비고 |
|------|------|------|------|
| JWT Key Pair (RS256) | 2048-bit | PEM | RSA 비대칭 키 쌍 (Private + Public) |
| JWT Key Pair (ES256) | 256-bit | PEM | EC P-256 비대칭 키 쌍 (Private + Public) |
| JWT Secret (HS256) | 256-bit | Hex | HMAC 대칭 키 |
| JWT Secret (HS384) | 384-bit | Hex | HMAC 대칭 키 |
| JWT Secret (HS512) | 512-bit | Hex | HMAC 대칭 키 |
| 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 | 자유 | 직접 선택 | |
---
@@ -36,6 +38,7 @@ python main.py
- **대량 생성** 체크박스 활성화 시 최대 20개 한번에 생성, **Copy All**로 전체 복사
- **Custom** 타입 선택 시 바이트 수(8~512)와 출력 포맷 직접 지정
- 모든 키는 Python `secrets` 모듈(암호학적 난수) 사용
- **RS256 / ES256** 선택 시 Private Key와 Public Key를 각각 개별 복사 가능 (웹 UI)
---
@@ -44,3 +47,4 @@ python main.py
- Python 3.8+
- customtkinter 5.2+
- pyperclip 1.9+
- cryptography 41.0+ (RS256 / ES256 키 쌍 생성)

Binary file not shown.

61
app.py
View File

@@ -5,12 +5,26 @@ import base64
import uuid
import os
import string
from cryptography.hazmat.primitives.asymmetric import rsa, ec
from cryptography.hazmat.primitives import serialization
from flask import Flask, render_template, request, jsonify
app = Flask(__name__)
KEY_CONFIGS = {
"jwt_rs256": {
"label": "JWT Key Pair (RS256)",
"description": "RSA 2048-bit 비대칭 키 쌍 (PEM)",
"bytes": None,
"format": "rsa_keypair",
},
"jwt_es256": {
"label": "JWT Key Pair (ES256)",
"description": "EC P-256 비대칭 키 쌍 (PEM)",
"bytes": None,
"format": "ec_keypair",
},
"jwt_hs256": {
"label": "JWT Secret (HS256)",
"description": "HMAC-SHA256용 JWT 시크릿 키",
@@ -85,9 +99,53 @@ def generate_key(key_type: str, custom_bytes: int = 32, custom_format: str = "he
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"]
if fmt == "rsa_keypair":
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
priv_pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
).decode()
pub_pem = private_key.public_key().public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
).decode()
return {
"key": priv_pem,
"public_key": pub_pem,
"keypair": True,
"type": key_type,
"label": config["label"],
"bits": 2048,
"length": len(priv_pem),
"algorithm": "RS256",
}
if fmt == "ec_keypair":
private_key = ec.generate_private_key(ec.SECP256R1())
priv_pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
).decode()
pub_pem = private_key.public_key().public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
).decode()
return {
"key": priv_pem,
"public_key": pub_pem,
"keypair": True,
"type": key_type,
"label": config["label"],
"bits": 256,
"length": len(priv_pem),
"algorithm": "ES256",
}
byte_length = config["bytes"] if config["bytes"] is not None else custom_bytes
raw = secrets.token_bytes(byte_length)
if fmt == "hex":
@@ -110,6 +168,7 @@ def generate_key(key_type: str, custom_bytes: int = 32, custom_format: str = "he
return {
"key": key,
"keypair": False,
"type": key_type,
"label": config["label"],
"bits": byte_length * 8,

33
main.py
View File

@@ -3,6 +3,8 @@ import base64
import uuid
import string
import pyperclip
from cryptography.hazmat.primitives.asymmetric import rsa, ec
from cryptography.hazmat.primitives import serialization
import customtkinter as ctk
from tkinter import messagebox
@@ -10,6 +12,8 @@ ctk.set_appearance_mode("dark")
ctk.set_default_color_theme("blue")
KEY_TYPES = [
("JWT Key Pair (RS256)", "jwt_rs256", "RSA 2048-bit · PEM", 256, "rsa_keypair"),
("JWT Key Pair (ES256)", "jwt_es256", "EC P-256 · PEM", 32, "ec_keypair"),
("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"),
@@ -34,6 +38,32 @@ FORMAT_MAP = {
def generate_key(byte_length: int, fmt: str) -> str:
if fmt == "rsa_keypair":
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
priv_pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
).decode()
pub_pem = private_key.public_key().public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
).decode()
return priv_pem + "\n" + pub_pem
if fmt == "ec_keypair":
private_key = ec.generate_private_key(ec.SECP256R1())
priv_pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
).decode()
pub_pem = private_key.public_key().public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
).decode()
return priv_pem + "\n" + pub_pem
raw = secrets.token_bytes(byte_length)
if fmt == "hex":
return raw.hex()
@@ -301,8 +331,9 @@ class App(ctk.CTk):
self._key_box.insert("1.0", key)
self._key_box.configure(state="disabled")
fmt_display = {"rsa_keypair": "RS256", "ec_keypair": "ES256"}.get(fmt, fmt.upper())
self._meta_label.configure(
text=f"{entry[0]} · {bits}-bit · {fmt.upper()} · {len(key)} chars"
text=f"{entry[0]} · {bits}-bit · {fmt_display} · {len(key)} chars"
)
# Reset copy button

View File

@@ -1,2 +1,3 @@
customtkinter>=5.2.0
pyperclip>=1.9.0
cryptography>=41.0.0

View File

@@ -375,6 +375,18 @@
margin-top: 10px;
}
/* Keypair display */
.keypair-section { margin-bottom: 16px; }
.keypair-section:last-child { margin-bottom: 0; }
.keypair-section-label {
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 6px;
}
/* Icon SVG */
svg { display: inline-block; vertical-align: middle; }
</style>
@@ -389,7 +401,7 @@
</svg>
Key Generator
</h1>
<p>JWT Secret · API Key · Operation Key · UUID · Random Hex</p>
<p>JWT Secret · RS256 · ES256 · API Key · Operation Key · UUID · Random Hex</p>
</header>
<div class="card">
@@ -463,6 +475,35 @@
</div>
</div>
<!-- Keypair result -->
<div id="resultKeypair" style="display:none">
<div class="keypair-section">
<div class="keypair-section-label">Private Key</div>
<div class="key-display" id="privateKeyDisplay" style="white-space:pre;font-size:0.75rem;"></div>
<div class="copy-row">
<button class="copy-btn" id="copyPrivateBtn" onclick="copyPrivateKey()">
<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 Private Key
</button>
</div>
</div>
<div class="keypair-section">
<div class="keypair-section-label">Public Key</div>
<div class="key-display" id="publicKeyDisplay" style="white-space:pre;font-size:0.75rem;"></div>
<div class="copy-row">
<button class="copy-btn" id="copyPublicBtn" onclick="copyPublicKey()">
<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 Public Key
</button>
</div>
</div>
<div class="key-meta" id="keyMetaKeypair"></div>
</div>
<!-- Bulk result -->
<div id="resultBulk" style="display:none">
<div class="bulk-list" id="bulkList"></div>
@@ -483,6 +524,8 @@
let selectedType = 'jwt_hs256';
let bulkMode = false;
let lastBulkKeys = [];
let lastBulkData = [];
let lastKeypair = null;
function selectType(type, el) {
selectedType = type;
@@ -541,11 +584,36 @@
}
}
const COPY_ICON = `<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>`;
const CHECK_ICON = `<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>`;
function renderSingle(data) {
document.getElementById('resultArea').style.display = 'block';
document.getElementById('resultSingle').style.display = 'block';
document.getElementById('resultBulk').style.display = 'none';
if (data.keypair) {
lastKeypair = data;
document.getElementById('resultSingle').style.display = 'none';
document.getElementById('resultKeypair').style.display = 'block';
document.getElementById('privateKeyDisplay').textContent = data.key;
document.getElementById('publicKeyDisplay').textContent = data.public_key;
const meta = document.getElementById('keyMetaKeypair');
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>
`;
document.getElementById('copyPrivateBtn').classList.remove('copied');
document.getElementById('copyPublicBtn').classList.remove('copied');
document.getElementById('copyPrivateBtn').innerHTML = `${COPY_ICON} Copy Private Key`;
document.getElementById('copyPublicBtn').innerHTML = `${COPY_ICON} Copy Public Key`;
} else {
lastKeypair = null;
document.getElementById('resultSingle').style.display = 'block';
document.getElementById('resultKeypair').style.display = 'none';
document.getElementById('keyDisplay').textContent = data.key;
const meta = document.getElementById('keyMeta');
@@ -558,52 +626,69 @@
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`;
copyBtn.innerHTML = `${COPY_ICON} Copy`;
}
}
function renderBulk(items) {
lastBulkData = items;
lastBulkKeys = items.map(i => i.key);
document.getElementById('resultArea').style.display = 'block';
document.getElementById('resultSingle').style.display = 'none';
document.getElementById('resultKeypair').style.display = 'none';
document.getElementById('resultBulk').style.display = 'block';
const list = document.getElementById('bulkList');
list.innerHTML = items.map((item, idx) => `
list.innerHTML = items.map((item, idx) => {
const displayKey = item.keypair
? item.key.split('\n').slice(0, 2).join('\n') + '\n...'
: item.key;
return `
<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('');
<span class="bulk-key" style="${item.keypair ? 'white-space:pre;font-size:0.72rem;' : ''}">${escHtmlDisplay(displayKey)}</span>
<button class="bulk-copy" data-index="${idx}" onclick="copyBulkItem(this)">Copy</button>
</div>`;
}).join('');
}
function escHtml(s) {
return s.replace(/'/g, "\\'");
function escHtmlDisplay(s) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function flashCopied(btn, resetLabel) {
btn.classList.add('copied');
btn.innerHTML = `${CHECK_ICON} Copied!`;
setTimeout(() => {
btn.classList.remove('copied');
btn.innerHTML = `${COPY_ICON} ${resetLabel}`;
}, 2000);
}
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);
flashCopied(document.getElementById('copyBtn'), 'Copy');
});
}
function copyBulkItem(btn, key) {
function copyPrivateKey() {
if (!lastKeypair) return;
navigator.clipboard.writeText(lastKeypair.key).then(() => {
flashCopied(document.getElementById('copyPrivateBtn'), 'Copy Private Key');
});
}
function copyPublicKey() {
if (!lastKeypair) return;
navigator.clipboard.writeText(lastKeypair.public_key).then(() => {
flashCopied(document.getElementById('copyPublicBtn'), 'Copy Public Key');
});
}
function copyBulkItem(btn) {
const idx = parseInt(btn.dataset.index);
const key = lastBulkData[idx].key;
navigator.clipboard.writeText(key).then(() => {
btn.classList.add('copied');
btn.textContent = 'Copied!';