fix: format keys as XXXX-XXXX-...-XXXX (8 groups), normalize before hashing

Keys are now human-readable grouped hex instead of raw 64-char blobs.
resolveKey() strips dashes before sha256/bcrypt so both formats work.
All key creation paths (register, reset-key, sub-keys) hash the
normalized form. Login placeholder and maxlength updated to match.
This commit is contained in:
M1 2026-03-17 06:25:19 +04:00
parent b8ac4e7b1f
commit 43a1abc2ed
2 changed files with 24 additions and 13 deletions

View File

@ -21,9 +21,9 @@
<form id="login-form" action="/account/login" method="POST">
<input type="hidden" name="_form" value="1">
<label class="block text-xs text-gray-500 uppercase tracking-wider mb-2">Account Key</label>
<input id="key-input" name="key" type="text" placeholder="Paste your account key" autocomplete="off" spellcheck="false"
class="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-3 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500 text-sm font-mono"
maxlength="64">
<input id="key-input" name="key" type="text" placeholder="XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX" autocomplete="off" spellcheck="false"
class="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-3 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500 text-sm font-mono tracking-wider"
maxlength="71">
<button type="submit"
class="w-full mt-3 bg-blue-600 hover:bg-blue-500 text-white font-medium py-3 rounded-lg transition-colors">
Sign In

View File

@ -3,7 +3,14 @@ import { randomBytes, createHash } from "crypto";
import sql from "../db";
function generateKey(): string {
return randomBytes(32).toString("hex");
const hex = randomBytes(32).toString("hex"); // 64 hex chars
// Format as 8 groups of 4: XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX
return (hex.match(/.{4}/g) as string[]).join("-").toUpperCase();
}
function normalizeKey(raw: string): string {
// Strip dashes/spaces so both formatted and raw hex keys resolve correctly
return raw.replace(/[-\s]/g, "").toLowerCase();
}
function sha256(data: string): string {
@ -21,12 +28,13 @@ function hashEmail(email: string): string {
* 3. Verify with bcrypt for extra security
*/
async function resolveKey(rawKey: string): Promise<{ accountId: string; keyId: string | null } | null> {
const lookup = sha256(rawKey);
const normalized = normalizeKey(rawKey);
const lookup = sha256(normalized);
// Check primary account key
const [account] = await sql`SELECT id, key_hash FROM accounts WHERE key_lookup = ${lookup}`;
if (account) {
const valid = await Bun.password.verify(rawKey, account.key_hash);
const valid = await Bun.password.verify(normalized, account.key_hash);
if (!valid) return null;
return { accountId: account.id, keyId: null };
}
@ -34,7 +42,7 @@ async function resolveKey(rawKey: string): Promise<{ accountId: string; keyId: s
// Check API sub-keys
const [apiKey] = await sql`SELECT id, account_id, key_hash FROM api_keys WHERE key_lookup = ${lookup}`;
if (apiKey) {
const valid = await Bun.password.verify(rawKey, apiKey.key_hash);
const valid = await Bun.password.verify(normalized, apiKey.key_hash);
if (!valid) return null;
sql`UPDATE api_keys SET last_used_at = now() WHERE id = ${apiKey.id}`.catch(() => {});
return { accountId: apiKey.account_id, keyId: apiKey.id };
@ -115,8 +123,9 @@ export const account = new Elysia({ prefix: "/account" })
// ── Register ────────────────────────────────────────────────────────
.post("/register", async ({ body, cookie }) => {
const rawKey = generateKey();
const keyLookup = sha256(rawKey);
const keyHash = await Bun.password.hash(rawKey, { algorithm: "bcrypt", cost: 10 });
const norm = normalizeKey(rawKey);
const keyLookup = sha256(norm);
const keyHash = await Bun.password.hash(norm, { algorithm: "bcrypt", cost: 10 });
const emailHash = body.email ? hashEmail(body.email) : null;
await sql`INSERT INTO accounts (key_lookup, key_hash, email_hash) VALUES (${keyLookup}, ${keyHash}, ${emailHash})`;
@ -163,8 +172,9 @@ export const account = new Elysia({ prefix: "/account" })
// Reset primary key — generates a new one, old one immediately invalid
.post("/reset-key", async ({ accountId, cookie }) => {
const rawKey = generateKey();
const keyLookup = sha256(rawKey);
const keyHash = await Bun.password.hash(rawKey, { algorithm: "bcrypt", cost: 10 });
const norm = normalizeKey(rawKey);
const keyLookup = sha256(norm);
const keyHash = await Bun.password.hash(norm, { algorithm: "bcrypt", cost: 10 });
await sql`UPDATE accounts SET key_lookup = ${keyLookup}, key_hash = ${keyHash} WHERE id = ${accountId}`;
@ -180,8 +190,9 @@ export const account = new Elysia({ prefix: "/account" })
// Create a sub-key (for different apps or shared access)
.post("/keys", async ({ accountId, body }) => {
const rawKey = generateKey();
const keyLookup = sha256(rawKey);
const keyHash = await Bun.password.hash(rawKey, { algorithm: "bcrypt", cost: 10 });
const norm = normalizeKey(rawKey);
const keyLookup = sha256(norm);
const keyHash = await Bun.password.hash(norm, { algorithm: "bcrypt", cost: 10 });
await sql`INSERT INTO api_keys (key_lookup, key_hash, account_id, label) VALUES (${keyLookup}, ${keyHash}, ${accountId}, ${body.label})`;
return { key: rawKey, label: body.label };