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:
parent
b8ac4e7b1f
commit
43a1abc2ed
|
|
@ -21,9 +21,9 @@
|
||||||
<form id="login-form" action="/account/login" method="POST">
|
<form id="login-form" action="/account/login" method="POST">
|
||||||
<input type="hidden" name="_form" value="1">
|
<input type="hidden" name="_form" value="1">
|
||||||
<label class="block text-xs text-gray-500 uppercase tracking-wider mb-2">Account Key</label>
|
<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"
|
<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"
|
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="64">
|
maxlength="71">
|
||||||
<button type="submit"
|
<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">
|
class="w-full mt-3 bg-blue-600 hover:bg-blue-500 text-white font-medium py-3 rounded-lg transition-colors">
|
||||||
Sign In
|
Sign In
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,14 @@ import { randomBytes, createHash } from "crypto";
|
||||||
import sql from "../db";
|
import sql from "../db";
|
||||||
|
|
||||||
function generateKey(): string {
|
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 {
|
function sha256(data: string): string {
|
||||||
|
|
@ -21,12 +28,13 @@ function hashEmail(email: string): string {
|
||||||
* 3. Verify with bcrypt for extra security
|
* 3. Verify with bcrypt for extra security
|
||||||
*/
|
*/
|
||||||
async function resolveKey(rawKey: string): Promise<{ accountId: string; keyId: string | null } | null> {
|
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
|
// Check primary account key
|
||||||
const [account] = await sql`SELECT id, key_hash FROM accounts WHERE key_lookup = ${lookup}`;
|
const [account] = await sql`SELECT id, key_hash FROM accounts WHERE key_lookup = ${lookup}`;
|
||||||
if (account) {
|
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;
|
if (!valid) return null;
|
||||||
return { accountId: account.id, keyId: 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
|
// Check API sub-keys
|
||||||
const [apiKey] = await sql`SELECT id, account_id, key_hash FROM api_keys WHERE key_lookup = ${lookup}`;
|
const [apiKey] = await sql`SELECT id, account_id, key_hash FROM api_keys WHERE key_lookup = ${lookup}`;
|
||||||
if (apiKey) {
|
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;
|
if (!valid) return null;
|
||||||
sql`UPDATE api_keys SET last_used_at = now() WHERE id = ${apiKey.id}`.catch(() => {});
|
sql`UPDATE api_keys SET last_used_at = now() WHERE id = ${apiKey.id}`.catch(() => {});
|
||||||
return { accountId: apiKey.account_id, keyId: apiKey.id };
|
return { accountId: apiKey.account_id, keyId: apiKey.id };
|
||||||
|
|
@ -115,8 +123,9 @@ export const account = new Elysia({ prefix: "/account" })
|
||||||
// ── Register ────────────────────────────────────────────────────────
|
// ── Register ────────────────────────────────────────────────────────
|
||||||
.post("/register", async ({ body, cookie }) => {
|
.post("/register", async ({ body, cookie }) => {
|
||||||
const rawKey = generateKey();
|
const rawKey = generateKey();
|
||||||
const keyLookup = sha256(rawKey);
|
const norm = normalizeKey(rawKey);
|
||||||
const keyHash = await Bun.password.hash(rawKey, { algorithm: "bcrypt", cost: 10 });
|
const keyLookup = sha256(norm);
|
||||||
|
const keyHash = await Bun.password.hash(norm, { algorithm: "bcrypt", cost: 10 });
|
||||||
const emailHash = body.email ? hashEmail(body.email) : null;
|
const emailHash = body.email ? hashEmail(body.email) : null;
|
||||||
|
|
||||||
await sql`INSERT INTO accounts (key_lookup, key_hash, email_hash) VALUES (${keyLookup}, ${keyHash}, ${emailHash})`;
|
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
|
// Reset primary key — generates a new one, old one immediately invalid
|
||||||
.post("/reset-key", async ({ accountId, cookie }) => {
|
.post("/reset-key", async ({ accountId, cookie }) => {
|
||||||
const rawKey = generateKey();
|
const rawKey = generateKey();
|
||||||
const keyLookup = sha256(rawKey);
|
const norm = normalizeKey(rawKey);
|
||||||
const keyHash = await Bun.password.hash(rawKey, { algorithm: "bcrypt", cost: 10 });
|
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}`;
|
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)
|
// Create a sub-key (for different apps or shared access)
|
||||||
.post("/keys", async ({ accountId, body }) => {
|
.post("/keys", async ({ accountId, body }) => {
|
||||||
const rawKey = generateKey();
|
const rawKey = generateKey();
|
||||||
const keyLookup = sha256(rawKey);
|
const norm = normalizeKey(rawKey);
|
||||||
const keyHash = await Bun.password.hash(rawKey, { algorithm: "bcrypt", cost: 10 });
|
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})`;
|
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 };
|
return { key: rawKey, label: body.label };
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue