toolkit/src/routes/api-key/+page.svelte
2026-03-15 16:34:31 -03:00

286 lines
7.4 KiB
Svelte

<svelte:head>
<title>API Key Generator</title>
</svelte:head>
<script lang="js">
import { copy } from '$lib/clipboard.js';
const ALPHABETS = {
hex: '0123456789abcdef',
base62: '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz',
base58: '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz',
alphanum: '0123456789abcdefghijklmnopqrstuvwxyz',
alpha: 'abcdefghijklmnopqrstuvwxyz',
};
/**
* Generate cryptographically secure random string from alphabet.
* Uses rejection sampling to avoid modulo bias.
* @param {string} alphabet
* @param {number} length
* @returns {string}
*/
function randomString(alphabet, length) {
const chars = [];
// Find largest multiple of alphabet.length that fits in a byte (rejection sampling)
const max = 256 - (256 % alphabet.length);
while (chars.length < length) {
const bytes = crypto.getRandomValues(new Uint8Array(length * 2));
for (const b of bytes) {
if (b < max) {
chars.push(alphabet[b % alphabet.length]);
if (chars.length === length) break;
}
}
}
return chars.join('');
}
/**
* Generate raw bytes as base64url (no padding).
* @param {number} byteCount
* @returns {string}
*/
function randomBase64url(byteCount) {
const bytes = crypto.getRandomValues(new Uint8Array(byteCount));
const base64 = btoa(String.fromCharCode(...bytes));
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
/**
* @param {number} bits
* @returns {string}
*/
function randomHex(bits) {
const bytes = crypto.getRandomValues(new Uint8Array(bits / 8));
return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
}
/**
* @typedef {{ id: string, label: string, desc: string }} Format
*/
/** @type {Format[]} */
const formats = [
{ id: 'hex128', label: 'hex-128', desc: '128-bit hex (32 chars)' },
{ id: 'hex256', label: 'hex-256', desc: '256-bit hex (64 chars)' },
{ id: 'base64url', label: 'base64url', desc: '256-bit base64url (43 chars)' },
{ id: 'base62_32', label: 'base62-32', desc: '32 chars base62 (~190 bits)' },
{ id: 'base62_43', label: 'base62-43', desc: '43 chars base62 (~256 bits)' },
{ id: 'base58_32', label: 'base58-32', desc: '32 chars base58 (~187 bits)' },
{ id: 'alphanum_32',label: 'alnum-32', desc: '32 lowercase alphanumeric (~165 bits)' },
{ id: 'custom', label: 'custom', desc: 'custom alphabet & length' },
];
/** @param {string} id @param {string} alphabet @param {number} customLen @returns {string} */
function generate(id, alphabet, customLen) {
switch (id) {
case 'hex128': return randomHex(128);
case 'hex256': return randomHex(256);
case 'base64url': return randomBase64url(32);
case 'base62_32': return randomString(ALPHABETS.base62, 32);
case 'base62_43': return randomString(ALPHABETS.base62, 43);
case 'base58_32': return randomString(ALPHABETS.base58, 32);
case 'alphanum_32': return randomString(ALPHABETS.alphanum, 32);
case 'custom': return randomString(alphabet || ALPHABETS.base62, customLen);
default: return '';
}
}
/** @param {string} alphabet @param {number} length @returns {number} */
const entropy = (alphabet, length) => Math.floor(Math.log2(alphabet.length) * length);
/** @param {string} id @param {string} customAlphabet @param {number} customLen @returns {number} */
function entropyFor(id, customAlphabet, customLen) {
switch (id) {
case 'hex128': return 128;
case 'hex256': return 256;
case 'base64url': return 256;
case 'base62_32': return entropy(ALPHABETS.base62, 32);
case 'base62_43': return entropy(ALPHABETS.base62, 43);
case 'base58_32': return entropy(ALPHABETS.base58, 32);
case 'alphanum_32': return entropy(ALPHABETS.alphanum, 32);
case 'custom': return entropy(customAlphabet || ALPHABETS.base62, customLen);
default: return 0;
}
}
// --- state ---
let selectedFormat = $state('hex256');
let prefix = $state('');
let customAlphabet = $state(ALPHABETS.base62);
let customLength = $state(32);
let batchCount = $state(5);
/** @type {{ key: string }[]} */
let keys = $state([]);
function regenerate() {
keys = Array.from({ length: batchCount }, () => ({
key: (prefix ? prefix + '_' : '') + generate(selectedFormat, customAlphabet, customLength),
}));
}
// generate on load
regenerate();
const currentEntropy = $derived(entropyFor(selectedFormat, customAlphabet, customLength));
</script>
<h1>API Key Generator</h1>
<h2>Format</h2>
<table class="formats">
<tbody>
{#each formats as f}
<tr>
<td>
<label>
<input type="radio" bind:group={selectedFormat} value={f.id} onchange={regenerate} />
{f.label}
</label>
</td>
<td class="desc">{f.desc}</td>
</tr>
{/each}
</tbody>
</table>
{#if selectedFormat === 'custom'}
<div class="custom-opts">
<label>
Alphabet:
<input type="text" bind:value={customAlphabet} size="50" spellcheck="false" oninput={regenerate} />
<span class="dim">({customAlphabet.length} chars)</span>
</label>
<br>
<label>
Length:
<input type="number" bind:value={customLength} min="8" max="256" size="5" oninput={regenerate} />
</label>
<br>
<div class="quick-alpha">
{#each Object.entries(ALPHABETS) as [name, abc]}
<button onclick={() => { customAlphabet = abc; regenerate(); }}>{name}</button>
{/each}
</div>
</div>
{/if}
<h2>Options</h2>
<p>
<label>
Prefix (optional):
<input type="text" bind:value={prefix} size="16" placeholder="sk, pk, token…" spellcheck="false" oninput={regenerate} />
</label>
&nbsp;
<label>
Count:
<input type="number" bind:value={batchCount} min="1" max="50" size="4" oninput={regenerate} />
</label>
</p>
<h2>Keys <span class="entropy">~{currentEntropy} bits of entropy</span></h2>
<div class="actions">
<button onclick={regenerate}>Regenerate all</button>
<button onclick={() => copy(keys.map(k => k.key).join('\n'))}>Copy all</button>
</div>
<table class="keys">
<tbody>
{#each keys as k, i}
<tr>
<td class="idx">{i + 1}</td>
<td><code>{k.key}</code></td>
<td>
<button onclick={() => {
k.key = (prefix ? prefix + '_' : '') + generate(selectedFormat, customAlphabet, customLength);
}}>new</button>
</td>
<td><button onclick={() => copy(k.key)}>copy</button></td>
</tr>
{/each}
</tbody>
</table>
<style>
.formats {
border-collapse: collapse;
margin-bottom: 8px;
}
.formats td {
padding: 2px 10px 2px 0;
}
.desc {
color: var(--fg-dim);
font-size: 0.9em;
}
.custom-opts {
border: 1px solid var(--border);
padding: 8px 12px;
margin-bottom: 8px;
background: var(--bg-panel);
}
.custom-opts label {
display: block;
margin-bottom: 4px;
}
.quick-alpha {
display: flex;
gap: 4px;
flex-wrap: wrap;
margin-top: 6px;
}
.dim {
color: var(--fg-faint);
font-size: 0.85em;
}
.entropy {
font-size: 0.75em;
font-weight: normal;
color: var(--green);
margin-left: 8px;
}
.actions {
display: flex;
gap: 6px;
margin-bottom: 6px;
}
.keys {
border-collapse: collapse;
width: 100%;
}
.keys td {
padding: 2px 6px;
border: 1px solid var(--border-faint);
}
.keys code {
font-size: 0.95em;
word-break: break-all;
}
.idx {
color: var(--fg-faint);
font-size: 0.85em;
width: 1px;
white-space: nowrap;
text-align: right;
}
.keys td:nth-child(3),
.keys td:nth-child(4) {
width: 1px;
white-space: nowrap;
}
</style>