refactor: drop all hashing, store keys plaintext

This commit is contained in:
M1 2026-03-17 06:47:22 +04:00
parent 54c89a5a11
commit e461d73ce3
3 changed files with 23 additions and 88 deletions

View File

@ -14,8 +14,7 @@ export async function migrate() {
await sql` await sql`
CREATE TABLE accounts ( CREATE TABLE accounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
key_lookup TEXT NOT NULL UNIQUE, key TEXT NOT NULL UNIQUE,
key_hash TEXT NOT NULL,
email_hash TEXT, email_hash TEXT,
created_at TIMESTAMPTZ DEFAULT now() created_at TIMESTAMPTZ DEFAULT now()
) )
@ -57,9 +56,7 @@ export async function migrate() {
await sql` await sql`
CREATE TABLE api_keys ( CREATE TABLE api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
key_lookup TEXT NOT NULL UNIQUE, key TEXT NOT NULL UNIQUE,
key_hash TEXT NOT NULL,
key_plain TEXT NOT NULL,
account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
label TEXT NOT NULL, label TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now(), created_at TIMESTAMPTZ DEFAULT now(),

View File

@ -1,47 +1,21 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import { randomBytes, createHash } from "crypto"; import { createHash } from "crypto";
import sql from "../db"; import sql from "../db";
function generateKey(): string { function generateKey(): string {
return crypto.randomUUID(); // standard UUID v4, 128-bit, familiar format return crypto.randomUUID();
}
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 {
return createHash("sha256").update(data).digest("hex");
} }
function hashEmail(email: string): string { function hashEmail(email: string): string {
return createHash("sha256").update(email.toLowerCase().trim()).digest("hex"); return createHash("sha256").update(email.toLowerCase().trim()).digest("hex");
} }
/** async function resolveKey(key: string): Promise<{ accountId: string; keyId: string | null } | null> {
* Resolves a raw key to an account. const [account] = await sql`SELECT id FROM accounts WHERE key = ${key}`;
* 1. Compute sha256 of the raw key for O(1) lookup if (account) return { accountId: account.id, keyId: null };
* 2. Query accounts or api_keys by key_lookup
* 3. Verify with bcrypt for extra security
*/
async function resolveKey(rawKey: string): Promise<{ accountId: string; keyId: string | null } | null> {
const normalized = normalizeKey(rawKey);
const lookup = sha256(normalized);
// Check primary account key const [apiKey] = await sql`SELECT id, account_id FROM api_keys WHERE key = ${key}`;
const [account] = await sql`SELECT id, key_hash FROM accounts WHERE key_lookup = ${lookup}`;
if (account) {
const valid = await Bun.password.verify(normalized, account.key_hash);
if (!valid) return null;
return { accountId: account.id, keyId: null };
}
// Check API sub-keys
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(normalized, apiKey.key_hash);
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 };
} }
@ -49,16 +23,13 @@ async function resolveKey(rawKey: string): Promise<{ accountId: string; keyId: s
return null; return null;
} }
// Exported for SSR use in dashboard route
export { resolveKey }; export { resolveKey };
export function requireAuth(app: Elysia) { export function requireAuth(app: Elysia) {
return app return app
.derive(async ({ headers, cookie, set }) => { .derive(async ({ headers, cookie, set }) => {
// 1. Bearer token (API clients) — case-insensitive
const authHeader = headers["authorization"] ?? ""; const authHeader = headers["authorization"] ?? "";
const bearer = authHeader.match(/^bearer\s+(.+)$/i)?.[1]?.trim(); const bearer = authHeader.match(/^bearer\s+(.+)$/i)?.[1]?.trim();
// 2. Cookie (dashboard / SSR)
const cookieKey = cookie?.pingql_key?.value; const cookieKey = cookie?.pingql_key?.value;
const key = bearer || cookieKey; const key = bearer || cookieKey;
@ -87,12 +58,11 @@ const COOKIE_OPTS = {
sameSite: "lax" as const, sameSite: "lax" as const,
path: "/", path: "/",
domain: process.env.COOKIE_DOMAIN ?? ".pingql.com", domain: process.env.COOKIE_DOMAIN ?? ".pingql.com",
maxAge: 60 * 60 * 24 * 365, // 1 year maxAge: 60 * 60 * 24 * 365,
}; };
export const account = new Elysia({ prefix: "/account" }) export const account = new Elysia({ prefix: "/account" })
// ── Login (sets cookie) ──────────────────────────────────────────────
.post("/login", async ({ body, cookie, set }) => { .post("/login", async ({ body, cookie, set }) => {
const key = (body.key as string)?.trim(); const key = (body.key as string)?.trim();
if (!key) { set.status = 400; return { error: "Key required" }; } if (!key) { set.status = 400; return { error: "Key required" }; }
@ -100,39 +70,27 @@ export const account = new Elysia({ prefix: "/account" })
const resolved = await resolveKey(key); const resolved = await resolveKey(key);
if (!resolved) { if (!resolved) {
set.status = 401; set.status = 401;
// If it's a form POST, redirect back with error
if ((body as any)._form) { set.redirect = "/dashboard?error=invalid"; return; } if ((body as any)._form) { set.redirect = "/dashboard?error=invalid"; return; }
return { error: "Invalid account key" }; return { error: "Invalid account key" };
} }
cookie.pingql_key.set({ value: key, ...COOKIE_OPTS }); cookie.pingql_key.set({ value: key, ...COOKIE_OPTS });
// Form POST → redirect to dashboard
if ((body as any)._form) { set.redirect = "/dashboard/home"; return; } if ((body as any)._form) { set.redirect = "/dashboard/home"; return; }
return { ok: true }; return { ok: true };
}, { detail: { hide: true } }) }, { detail: { hide: true } })
// ── Logout ───────────────────────────────────────────────────────────
.get("/logout", ({ cookie, set }) => { .get("/logout", ({ cookie, set }) => {
cookie.pingql_key.remove(); cookie.pingql_key.remove();
set.redirect = "/dashboard"; set.redirect = "/dashboard";
}, { detail: { hide: true } }) }, { detail: { hide: true } })
// ── Register ────────────────────────────────────────────────────────
.post("/register", async ({ body, cookie }) => { .post("/register", async ({ body, cookie }) => {
const rawKey = generateKey(); const key = generateKey();
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; const emailHash = body.email ? hashEmail(body.email) : null;
await sql`INSERT INTO accounts (key, email_hash) VALUES (${key}, ${emailHash})`;
await sql`INSERT INTO accounts (key_lookup, key_hash, email_hash) VALUES (${keyLookup}, ${keyHash}, ${emailHash})`; cookie.pingql_key.set({ value: key, ...COOKIE_OPTS });
// Set cookie so user is immediately logged in
cookie.pingql_key.set({ value: rawKey, ...COOKIE_OPTS });
return { return {
key: rawKey, key,
...(body.email ? { email_registered: true } : { email_registered: false }), ...(body.email ? { email_registered: true } : { email_registered: false }),
}; };
}, { }, {
@ -141,13 +99,11 @@ export const account = new Elysia({ prefix: "/account" })
}), }),
}) })
// ── Auth-required routes below ───────────────────────────────────────
.use(requireAuth) .use(requireAuth)
// Get account settings
.get("/settings", async ({ accountId }) => { .get("/settings", async ({ accountId }) => {
const [acc] = await sql`SELECT id, email_hash, created_at FROM accounts WHERE id = ${accountId}`; const [acc] = await sql`SELECT id, email_hash, created_at FROM accounts WHERE id = ${accountId}`;
const keys = await sql`SELECT id, key_plain, label, created_at, last_used_at FROM api_keys WHERE account_id = ${accountId} ORDER BY created_at DESC`; const keys = await sql`SELECT id, key, label, created_at, last_used_at FROM api_keys WHERE account_id = ${accountId} ORDER BY created_at DESC`;
return { return {
account_id: acc.id, account_id: acc.id,
has_email: !!acc.email_hash, has_email: !!acc.email_hash,
@ -156,7 +112,6 @@ export const account = new Elysia({ prefix: "/account" })
}; };
}) })
// Update email
.post("/email", async ({ accountId, body }) => { .post("/email", async ({ accountId, body }) => {
const emailHash = body.email ? hashEmail(body.email) : null; const emailHash = body.email ? hashEmail(body.email) : null;
await sql`UPDATE accounts SET email_hash = ${emailHash} WHERE id = ${accountId}`; await sql`UPDATE accounts SET email_hash = ${emailHash} WHERE id = ${accountId}`;
@ -167,40 +122,23 @@ export const account = new Elysia({ prefix: "/account" })
}), }),
}) })
// 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 key = generateKey();
const norm = normalizeKey(rawKey); await sql`UPDATE accounts SET key = ${key} WHERE id = ${accountId}`;
const keyLookup = sha256(norm); cookie.pingql_key.set({ value: key, ...COOKIE_OPTS });
const keyHash = await Bun.password.hash(norm, { algorithm: "bcrypt", cost: 10 }); return { key, message: "Primary key rotated. Your old key is now invalid." };
await sql`UPDATE accounts SET key_lookup = ${keyLookup}, key_hash = ${keyHash} WHERE id = ${accountId}`;
// Set the new key as the cookie so the user stays logged in
cookie.pingql_key.set({ value: rawKey, ...COOKIE_OPTS });
return {
key: rawKey,
message: "Primary key rotated. Your old key is now invalid.",
};
}) })
// Create a sub-key (for different apps or shared access)
.post("/keys", async ({ accountId, body }) => { .post("/keys", async ({ accountId, body }) => {
const rawKey = generateKey(); const key = generateKey();
const norm = normalizeKey(rawKey); const [created] = await sql`INSERT INTO api_keys (key, account_id, label) VALUES (${key}, ${accountId}, ${body.label}) RETURNING id`;
const keyLookup = sha256(norm); return { key, id: created.id, label: body.label };
const keyHash = await Bun.password.hash(norm, { algorithm: "bcrypt", cost: 10 });
const [created] = await sql`INSERT INTO api_keys (key_lookup, key_hash, key_plain, account_id, label) VALUES (${keyLookup}, ${keyHash}, ${rawKey}, ${accountId}, ${body.label}) RETURNING id`;
return { key: rawKey, id: created.id, label: body.label };
}, { }, {
body: t.Object({ body: t.Object({
label: t.String({ description: "A name for this key, e.g. 'ci-pipeline' or 'mobile-app'" }), label: t.String({ description: "A name for this key, e.g. 'ci-pipeline' or 'mobile-app'" }),
}), }),
}) })
// Delete a sub-key
.delete("/keys/:id", async ({ accountId, params, error }) => { .delete("/keys/:id", async ({ accountId, params, error }) => {
const [deleted] = await sql` const [deleted] = await sql`
DELETE FROM api_keys WHERE id = ${params.id} AND account_id = ${accountId} RETURNING id DELETE FROM api_keys WHERE id = ${params.id} AND account_id = ${accountId} RETURNING id

View File

@ -78,8 +78,8 @@
<button onclick="deleteKey('<%= k.id %>', this)" class="text-xs text-gray-600 hover:text-red-400 transition-colors px-2 py-1">Revoke</button> <button onclick="deleteKey('<%= k.id %>', this)" class="text-xs text-gray-600 hover:text-red-400 transition-colors px-2 py-1">Revoke</button>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<code class="flex-1 bg-gray-900 border border-gray-800 rounded-lg px-3 py-2 text-blue-400 text-xs font-mono select-all"><%= k.key_plain %></code> <code class="flex-1 bg-gray-900 border border-gray-800 rounded-lg px-3 py-2 text-blue-400 text-xs font-mono select-all"><%= k.key %></code>
<button onclick="copyKey(this, '<%= k.key_plain %>')" class="px-3 bg-gray-800 hover:bg-gray-700 border border-gray-700 rounded-lg text-gray-400 hover:text-white text-xs transition-colors">Copy</button> <button onclick="copyKey(this, '<%= k.key %>')" class="px-3 bg-gray-800 hover:bg-gray-700 border border-gray-700 rounded-lg text-gray-400 hover:text-white text-xs transition-colors">Copy</button>
</div> </div>
<p class="text-xs text-gray-600 mt-1.5">created <%= new Date(k.created_at).toLocaleDateString() %> <%~ k.last_used_at ? '· last used ' + it.timeAgoSSR(k.last_used_at) : '· never used' %></p> <p class="text-xs text-gray-600 mt-1.5">created <%= new Date(k.created_at).toLocaleDateString() %> <%~ k.last_used_at ? '· last used ' + it.timeAgoSSR(k.last_used_at) : '· never used' %></p>
</div> </div>